DEV Community

Cover image for Building a Password Manager in Go: Part 3
Srikanth
Srikanth

Posted on

Building a Password Manager in Go: Part 3

Welcome back to our journey of building a password manager in Go! In this third installment, we've made significant strides in functionality and usability. Let's dive deep into the exciting new features and improvements.

1. Implementing Password Storage

One of the most crucial additions to our password manager is the ability to store and retrieve passwords. We've implemented a Storage struct that handles these operations:

type Storage struct {
    path string
}

func GetStorage(path string) *Storage {
    s := Storage{path: path}
    return &s
}

func (s *Storage) Init() {
    if err := os.Mkdir(s.path, 0755); err != nil {
        panic(err)
    }
}

func (s *Storage) Add(password, identifier string) error {
    directory := filepath.Dir(identifier)
    dirPath := filepath.Join(s.path, directory)
    if _, err1 := os.Stat(dirPath); os.IsNotExist(err1) {
        log.Printf("%s does not exist, creating.", dirPath)
        if err3 := os.Mkdir(dirPath, 0755); err3 != nil {
            return err3
        } 
    }
    absPath := filepath.Join(s.path, identifier)
    if err2 := os.WriteFile(absPath, []byte(password), 0600); err2 != nil {
        log.Println("Error writing file:", err2)
        return err2
    }
    return nil
}

func (s *Storage) Show(identifier string) (string, error) {
    absPath := filepath.Join(s.path, identifier)
    password, err := os.ReadFile(absPath)
    if err != nil {
        return "", err
    }
    return string(password), nil
}

func (s *Storage) IsReady() bool {
    _, err := os.Stat(s.path)
    return !os.IsNotExist(err)
}
Enter fullscreen mode Exit fullscreen mode

Let's break down these methods:

  • GetStorage: Creates a new Storage instance with a specified path.
  • Init: Initializes the storage directory.
  • Add: Stores a password, creating directories as needed.
  • Show: Retrieves a stored password.
  • IsReady: Checks if the storage has been initialized.

This implementation allows for a hierarchical storage structure, enabling users to organize passwords in categories (e.g., email/work@example.com).

2. Enhanced Command-Line Interface

We've significantly improved our CLI, adding new commands and better user guidance:

func main() {
    path := os.Getenv("HOME") + "/.dost"
    storage := internal.GetStorage(path)
    generateCmd := flag.NewFlagSet("generate", flag.ExitOnError)
    flag.Parse()

    if len(os.Args) < 2 {
        printHelp()
        os.Exit(1)
    }

    switch os.Args[1] {
    case "generate":
        if storage.IsReady() {
            password, passwordName := internal.Generate(generateCmd)
            if err := storage.Add(password, passwordName); err != nil {
                fmt.Printf("%v", err)
            }
        } else {
            fmt.Println("dost is not initialized. Run `dost init`")
            os.Exit(1)
        }
    case "init":
        storage.Init()
    case "show":
        password, err := storage.Show(os.Args[2])
        if err != nil {
            fmt.Printf("Something went wrong: %v", err)
        } else {
            fmt.Println(password)
        }
    default:
        printHelp()
    }
}
Enter fullscreen mode Exit fullscreen mode

This new structure allows for three main commands:

  • init: Initializes the password store.
  • generate: Generates a new password and optionally stores it.
  • show: Retrieves a stored password.

3. User-Friendly Help Function

To make our tool more user-friendly, we've added a printHelp() function:

func printHelp() {
    fmt.Println("Invalid command to run dost password manager.")
    fmt.Println("Please choose one of the following options:")
    fmt.Println("dost init")
    fmt.Println("dost generate [-c] [-n] <password-name>")
    fmt.Println("dost show <password-name>")
}
Enter fullscreen mode Exit fullscreen mode

This function is called when users provide invalid input, guiding them on how to use the password manager correctly.

4. Improved Password Generation

We've refined our password generation algorithm to ensure a good mix of character types:

func generatePassword(length int, noSymbols bool) (string, error) {
    allChars := uppercaseLetters + lowercaseLetters + digits
    var password []string

    password = append(password, selectRandomCharacter(uppercaseLetters))
    password = append(password, selectRandomCharacter(lowercaseLetters))
    password = append(password, selectRandomCharacter(digits))

    if !noSymbols {
        allChars += specialChars
        password = append(password, selectRandomCharacter(specialChars))
    }

    length -= len(password)

    for i := 0; i < length; i++ {
        randomIndex, err := rand.Int(rand.Reader, big.NewInt(int64(len(allChars))))
        if err != nil {
            return "", err
        }
        password = append(password, string(allChars[randomIndex.Int64()]))
    }

    password = shuffle(password)
    return strings.Join(password, ""), nil
}
Enter fullscreen mode Exit fullscreen mode

This approach guarantees that each password includes at least one uppercase letter, one lowercase letter, one digit, and (unless specified otherwise) one special character. The shuffle function ensures that these mandatory characters are not always in the same position.

5. Comprehensive Testing

We've expanded our test suite to cover new functionalities:

func TestStorageInitReady(t *testing.T) {
    path := getRandomPath()
    defer cleanUpPath(path)

    storage := GetStorage(path)
    storage.Init()

    if !storage.IsReady() {
        t.Errorf("Expected directory: %s to be created on storage.Init()", path)
    }
}

func TestStorageAddShow(t *testing.T) {
    path := getRandomPath()
    defer cleanUpPath(path)

    storage := GetStorage(path)
    storage.Init()

    identifier := "email/sri@example.com"
    password := "someRandomPassword"

    addErr := storage.Add(password, identifier)
    if addErr != nil {
        t.Errorf("Did not expect an error when calling storage.Add: \n%v", addErr)
    }

    passwordFromFile, showErr := storage.Show(identifier)
    if showErr != nil {
        t.Errorf("Did not expect an error when calling storage.Show: \n%v", showErr)
    }

    if passwordFromFile != password {
        t.Errorf("Password that was added did not match the one from the one that got saved\npassword: %s, passwordFromFile: %s", 
                password, passwordFromFile)
    }
}
Enter fullscreen mode Exit fullscreen mode

These tests ensure that our storage mechanisms work correctly, adding an extra layer of reliability to our password manager. We're using random paths for testing to avoid conflicts and cleaning up after each test.

6. Environment-Aware Storage Location

We've made our password manager more user-friendly by storing passwords in the user's home directory:

path := os.Getenv("HOME") + "/.dost"
storage := internal.GetStorage(path)
Enter fullscreen mode Exit fullscreen mode

This approach ensures that the password store is easily accessible and follows common conventions for user-specific data storage.

What's Next?

While we've made significant progress, there's still room for improvement:

  1. Implement encryption for stored passwords to enhance security.
  2. Add a feature to update existing passwords.
  3. Implement a search functionality for stored passwords.
  4. Add support for importing and exporting passwords for backup purposes.

Conclusion

In this iteration, we've transformed our password manager from a simple password generator to a functional tool for storing and retrieving passwords. We've improved the user experience with better CLI interactions and added robust testing to ensure reliability.

The journey of building this password manager has been an excellent opportunity to explore various aspects of Go programming, from file I/O to testing and CLI development. As we continue to develop this tool, we're not just creating a password manager; we're also honing our Go skills and exploring best practices in software development.

Stay tuned for the next part of our series, where we'll tackle these challenges and continue to evolve our password manager!

Remember, the full source code is available on GitHub. Feel free to clone, fork, and contribute to the project. Your feedback and contributions are always welcome!

Happy coding, and stay secure! πŸ”πŸ’»

GitHub logo svemaraju / dost

dost command line password manager written in Go

dost

dost is a CLI password manager written in Go.

Inspired by (Pass)[https://www.passwordstore.org/]

Features

  • Generate random passwords of configurable length
  • Copy generated passwords to clipboard automatically
  • Skip using symbols

Usage

> go build -o dost main.go
Enter fullscreen mode Exit fullscreen mode

Generating password:

> ./dost generate email/vema@example.com
Generated Password: );XE,7-Dv?)Aa+&<{V-|pKuq5

Generating password with specified length (default is 25):

> ./dost generate email/vema@example.com 12
Generated Password: si<yJ=5/lEb3

Copy generated password to clipboard without printing:

> ./dost generate -c email/vema@example.com 
Copied to clipboard! βœ…

Avoid symbols for generating passwords:

> ./dost generate -n email/vema@example.com 
Generated Password: E2UST}^{Ac[Fb&D|cD%;Eij>H

Under development

  • Insert a new password manually
  • Show an existing password
  • List all entries
  • Password storage
  • GPG Key based encryption

License

MIT






Top comments (0)