A Go password manager for the terminal

Armoring for Transfer

The O_TRUNC option rewinds the write location in an existing password file to the beginning so that subsequent print commands simply overwrite it. Now, the binary data in the encrypted file might confuse editors, or a network transfer program might be tempted to restructure binary sequences while transferring the content over to a remote location. This is why line 20 sets up an armor type writer to re-encode the binary data, which will then still appear scrambled, but at least as lines of manageable length and without non-printable characters (Figure 3).

Figure 3: The encrypted password file on the hard disk.

The functions used in Listing 2 are a good illustration of the writer mechanism so often used in Go. A writer always takes data from somewhere and then writes it somewhere else. For example, OpenFile() in line 15 opens a file and returns a writer object named out. The armor mechanism from the armor package takes this writer object and returns its own writer object, armorWriter. This object, in turn, is taken by the Encrypt() function in line 22, which then returns another writer w, writing encrypted data, to which line 27 then starts writing to using io.WriteString().

In other words, the code implements a contraption of nested functions reminiscent of a Unix pipe. The first stage takes the clear-text data, and the armored and encrypted data drops out at the end. Thanks to the writer interface supported by Go, the functions in the chain don't have to worry about the type of data they are transporting: As long as every link in the chain supports the writer interface, everything runs like clockwork.

In our case, the Age functions even use the WriteCloser interface, which supports both the Write() and Close() calls. The Close() calls are extremely important for buffered output. If the Close() call is left out, any memory caches used in the pipe may not be cleared at the end, and a truncated and therefore unreadable mess of data will quickly accumulate at the end of the pipe.

Encrypted Read

Conversely, readEnc() reads data from the encrypted password file starting in line 32 and returns the clear-text data as a string. To do this, the function takes the master password for symmetric encryption as a string and upgrades it to an Identity object, for the call to the Decrypt() function later on in line 44.

But again, the reader must first get through the armor of the encrypted data. This is done by the armor type Reader in line 43 which in turn receives another reader object for the open password file as a parameter. To read the data from the armor breaker's reader, decrypt it, and store it in a string for return, line 48 uses io.Copy() to vacuum up all the reader data and drop the data into the waiting out bytes buffer. The buffer's String() method turns the byte array into a string, while line 51 returns the clear-text data to the caller.

I don't want the entries in the password viewer's listbox to come up immediately in full glory when the UI appears later. Rather, I only want to be able to read the first word of each line, while asterisks obfuscate everything else. To do this, the mask() utility function in Listing 3 takes a string, iterates over its characters, and replaces them with an asterisk if the tomask flag is set. Initially, this is not the case until line 11 detects a space in the string. The algorithm then thinks it has reached the position to the right of the first word. When it gets there, it sets tomask to true and hides the rest of the string under asterisks.

Listing 3


01 package main
02 func mask(s string) string {
03   masked := []byte(s)
04   tomask := false
05   for i := 0; i < len(s); i++ {
06     if tomask {
07       masked[i] = '*'
08     } else {
09       masked[i] = s[i]
10     }
11     if s[i] == ' ' {
12       tomask = true
13     }
14   }
15   return string(masked)
16 }

The main() function in Listing 4 checks for the optional --add command-line argument using the flag package. If the flag is set, the if block in line 25 jumps to the code starting in line 26, which collects a new user password entry from standard input and appends it to the text of the previously decrypted password file.

Listing 4


01 package main
02 import (
03   "bufio"
04   "errors"
05   "flag"
06   "fmt"
07   "golang.org/x/crypto/ssh/terminal"
08   "os"
09   "strings"
10 )
11 func main() {
12   add := flag.Bool("add", false, "Add new password entry")
13   flag.Parse()
14   fmt.Printf("Password: ")
15   password, err := terminal.ReadPassword(int(os.Stdin.Fd()))
16   if err != nil {
17     panic(err)
18   }
19   txt, err := readEnc(string(password))
20   if err != nil {
21     if !errors.Is(err, os.ErrNotExist) {
22       panic(err)
23     }
24   }
25   if *add {
26     fmt.Printf("\rNew entry: ")
27     reader := bufio.NewReader(os.Stdin)
28     entry, _ := reader.ReadString('\n')
29     txt = txt + entry
30     writeEnc(txt, string(password))
31     return
32   }
33   lines := strings.Split(strings.TrimSuffix(txt, "\n"), "\n")
34   runUI(lines)
35 }

No File, No Problem

To do this, line 14 displays the Password: prompt for collecting the master password. Line 15 reads it using the standard terminal package via its ReadPassword() function. The ReadPassword() function turns off the terminal's echo, so the user can type the password without it being displayed. If the password does not match the one originally set for the password file, readEnc() in line 19 fails and panic() in line 22 aborts the program. But if readEnc() fails because the password file does not yet exist, line 21 traps this and tells the program to continue until either a new entry is appended later or the empty file is displayed in the UI.

On the text of the decrypted file, line 33 uses TrimSuffix() to remove the last newline character and then employs Split() to split the whole blob into an array of line strings; both functions are from the standard strings package. Line 34 then passes the array to the runUI() function, telling it to launch the UI. The UI keeps running until the user hits the quit button, ending the main program, and shutting down the UI.

Buy this article as PDF

Express-Checkout as PDF
Price $2.95
(incl. VAT)

Buy Linux Magazine

Get it on Google Play

US / Canada

Get it on Google Play

UK / Australia

Related content

comments powered by Disqus
Subscribe to our Linux Newsletters
Find Linux and Open Source Jobs
Subscribe to our ADMIN Newsletters

Support Our Work

Linux Magazine content is made possible with support from readers like you. Please consider contributing when you’ve found an article to be beneficial.

Learn More