From 01f249d4846deb70ceb3d98af5d7c2d31593817a Mon Sep 17 00:00:00 2001 From: James Shubin Date: Fri, 29 Sep 2023 15:33:54 -0400 Subject: [PATCH] util: password: Add a cancellable ReadPassword style functions It was non-trivial to do this, so I put it into a library. Strangely I couldn't directly wrap the ReadPassword function from the golang.org/x/term package, as it wouldn't unblock for some reason. --- util/password/password.go | 141 +++++++++++++++++++++++++++++++++ util/password/password_test.go | 68 ++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 util/password/password.go create mode 100644 util/password/password_test.go diff --git a/util/password/password.go b/util/password/password.go new file mode 100644 index 00000000..e12a1ffa --- /dev/null +++ b/util/password/password.go @@ -0,0 +1,141 @@ +// Mgmt +// Copyright (C) 2013-2023+ James Shubin and the project contributors +// Written by James Shubin and the project contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package password + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "runtime" + "sync" + "syscall" + "time" + + "golang.org/x/sys/unix" +) + +const ( + // StdPrompt is the usual text that we would use to ask for a password. + StdPrompt = "Password: " + + // XXX: these two are different on BSD, and were taken from: + // golang.org/x/term/term_unix_other.go + ioctlReadTermios = unix.TCGETS + ioctlWriteTermios = unix.TCSETS +) + +// ReadPassword reads a password from stdin and returns the result. It hides the +// display of the password typed. For more options try ReadPasswordCtxFdPrompt +// instead. If interrupted by an uncaught signal during read, then this can bork +// your terminal. It's best to use a version with a context instead. +func ReadPassword() ([]byte, error) { + return ReadPasswordCtxFdPrompt(context.Background(), int(os.Stdin.Fd()), StdPrompt) +} + +// ReadPasswordCtx reads a password from stdin and returns the result. It hides +// the display of the password typed. It cancels reading when the context +// closes. For more options try ReadPasswordCtxFdPrompt instead. If interrupted +// by an uncaught signal during read, then this can bork your terminal. It's +// best to use a version with a context instead. +func ReadPasswordCtx(ctx context.Context) ([]byte, error) { + return ReadPasswordCtxFdPrompt(ctx, int(os.Stdin.Fd()), StdPrompt) +} + +// ReadPasswordCtxFdPrompt reads a password from the file descriptor and returns +// the result. It hides the display of the password typed. It cancels reading +// when the context closes. If specified, it will prompt the user with the +// prompt message. If interrupted by an uncaught signal during read, then this +// can bork your terminal. +func ReadPasswordCtxFdPrompt(ctx context.Context, fd int, prompt string) ([]byte, error) { + + // XXX: https://github.com/golang/go/issues/24842 + if err := syscall.SetNonblock(fd, true); err != nil { + return nil, err + } + file := os.NewFile(uintptr(fd), "") // XXX: name? + + // We do some term magic to not print the password. This is taken from: + // golang.org/x/term/term_unix.go:readPassword + termios, err := unix.IoctlGetTermios(fd, ioctlReadTermios) + if err != nil { + return nil, err + } + newState := *termios + newState.Lflag &^= unix.ECHO + newState.Lflag |= unix.ICANON | unix.ISIG + newState.Iflag |= unix.ICRNL + if err := unix.IoctlSetTermios(fd, ioctlWriteTermios, &newState); err != nil { + return nil, err + } + defer unix.IoctlSetTermios(fd, ioctlWriteTermios, termios) + + wg := &sync.WaitGroup{} + defer wg.Wait() + ctx, cancel := context.WithCancel(ctx) + defer cancel() + wg.Add(1) + go func() { + defer wg.Done() + <-ctx.Done() + file.SetReadDeadline(time.Now()) + }() + + if prompt != "" { + fmt.Print(prompt) // prints because we only turned off echo on fd + } + + // This previously didn't pass through the deadline. This is taken from: + // golang.org/x/term/terminal.go:readPasswordLine + var buf [1]byte + var ret []byte + for { + n, err := file.Read(buf[:]) // unblocks on SetReadDeadline(now) + if n > 0 { + switch buf[0] { + case '\b': + if len(ret) > 0 { + ret = ret[:len(ret)-1] + } + case '\n': + if runtime.GOOS != "windows" { + return ret, nil + } + // otherwise ignore \n + case '\r': // lol + if runtime.GOOS == "windows" { + return ret, nil + } + // otherwise ignore \r + default: + ret = append(ret, buf[0]) + } + continue + } + if e := ctx.Err(); errors.Is(err, os.ErrDeadlineExceeded) && e != nil { + return nil, e + } + if err != nil { + if err == io.EOF && len(ret) > 0 { + return ret, nil + } + return ret, err // XXX: why ret and not nil? + } + } +} diff --git a/util/password/password_test.go b/util/password/password_test.go new file mode 100644 index 00000000..b28daffc --- /dev/null +++ b/util/password/password_test.go @@ -0,0 +1,68 @@ +// Mgmt +// Copyright (C) 2013-2023+ James Shubin and the project contributors +// Written by James Shubin and the project contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +//go:build !root + +package password + +import ( + "context" + "fmt" + "os" + "os/signal" + "sync" + "syscall" + "time" +) + +func ExampleReadPasswordCtx() { + // Put this in a main function and it will not have the ioctl error! + fmt.Println("hello") + defer fmt.Println("exiting...") + + wg := &sync.WaitGroup{} + defer wg.Wait() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // If we exit without getting a chance to reset the terminal, it might + // be borked! + ch := make(chan os.Signal, 1+1) // must have buffer for max number of signals + signal.Notify(ch, syscall.SIGTERM, os.Interrupt) + wg.Add(1) + go func() { + defer wg.Done() + select { + case <-ch: + cancel() + case <-ctx.Done(): + } + }() + + password, err := ReadPasswordCtx(ctx) + if err != nil { + fmt.Printf("error: %+v\n", err) + return + } + + fmt.Printf("password is: %s\n", string(password)) + + // Output: hello + // error: inappropriate ioctl for device + // exiting... +}