Compare commits

...

43 commits

Author SHA1 Message Date
d72e124a47 chore: update libs 2024-10-12 10:17:51 +02:00
b573fbf100 fix: bug in search bar 2021-01-02 18:43:57 +01:00
48326bf95b fix: choice box for generate new password was ignored 2021-01-02 18:15:19 +01:00
302ac2abe2 release: version 2.0.0 2020-12-23 14:15:16 +01:00
5455569895 fix: update SearchEntry in test 2020-12-23 09:57:47 +01:00
ce5563d989 feat: add select box for group in a form 2020-12-22 18:41:04 +01:00
0bf248d70e fix: search entry with no group 2020-12-22 17:46:28 +01:00
039b430e7d fix: minor syntax changes 2020-12-22 17:11:02 +01:00
4f35213585 fix: show help when an item is selected 2020-07-14 18:59:31 +02:00
a28d2060b3 fix: minor change 2020-07-14 18:53:30 +02:00
3d728e4dc5 feat: add notification box when add value in clipboard 2020-07-04 09:19:52 +02:00
59c5d7e625 feat: add help box 2020-07-04 08:54:29 +02:00
e7ec6fdd20 chore: add comment 2020-03-27 21:56:53 +01:00
dcf1e38c1e minor change 2019-11-07 21:36:49 +01:00
b5189c1762 feat: lock the wallet 300 seconds after the last action 2019-10-21 21:42:24 +02:00
d6e7bbba2b fix 2019-10-21 19:25:47 +02:00
efc4ec1f90 fix: minor change 2019-10-21 18:57:35 +02:00
6a2f4aaa3e feat: add clibboard 2019-10-20 22:14:50 +02:00
f6bb6789f6 fix: minor change 2019-10-20 21:50:21 +02:00
4b775101c8 feat: add import and export 2019-10-20 20:10:09 +02:00
9f0043786a feat: add timeout to close 2019-10-20 17:47:50 +02:00
20b4334759 feat: add remove entry 2019-10-20 16:16:39 +02:00
d055cc34ec feat: add choice box 2019-10-20 16:03:26 +02:00
00336289c4 chore: minor change 2019-10-20 15:14:21 +02:00
9413df0cd3 feat: add update netry 2019-10-20 15:03:31 +02:00
d2de75a47b feat: add entry 2019-10-20 12:30:23 +02:00
d4dee06902 feat: add group filter 2019-10-20 11:20:34 +02:00
6c30928065 feat: new interface 2019-10-20 11:09:25 +02:00
c47c6719d2 release: version 1.2.1 2019-10-19 11:32:30 +02:00
45aecb7a66 fix: bug in select item 2019-10-19 08:24:44 +02:00
b066a34a45 release: version 1.2.0 2019-09-01 19:27:51 +02:00
87bfdf0505 feat: add timeout to close wallet for the security 2019-09-01 17:00:19 +02:00
3f9d4850b6 style: change space to tab for goreport 2019-08-31 19:18:35 +02:00
30ada1a1fc feat: add test for config 2019-08-08 18:27:31 +02:00
b63af478e3 feat: add test for wallet 2019-08-08 13:04:55 +02:00
905c175578 feat: search is case insensite 2019-08-05 12:40:56 +02:00
f62438f22d feat: add test for crypto 2019-07-27 10:28:13 +02:00
1218eeaeac feat: add test for entry 2019-07-27 09:00:23 +02:00
27ce2d499d feat: use RandomString function for wallet's salt 2019-07-25 07:46:04 +02:00
be8d5dfe60 feat: add entry's fields Create and LastUpdate 2019-07-24 19:01:31 +02:00
48a47144a1 feat: export in a file 2019-07-24 18:48:47 +02:00
5fb2c5ee1c feat: add test build with travis 2019-07-24 18:34:45 +02:00
38295c6fb6 style: minor change in CHANGELOG 2019-07-24 18:20:15 +02:00
21 changed files with 1630 additions and 905 deletions

View file

@ -7,6 +7,42 @@ Which is based on [Keep A Changelog](http://keepachangelog.com/)
## Unreleased
### Changed
- Update libs
### Fixed
- Choice box for generate new password was ignored
- Bug in search bar
## v2.0.0 - 2020-12-23
### Changed
- New interface
## v1.2.1 - 2019-10-19
### Fixed
- Bug in select item
## v1.2.0 - 2019-09-01
### Added
- Test build with travis
- Add entry's fields Create and LastUpdate
- Add timeout to close wallet for the security
### Changed
- Export in a file
- Use RandomString function for wallet's salt
- Search is case insensite
- change space to tab for goreport
## v1.1.0 - 2019-07-23
### Added
@ -18,7 +54,7 @@ Which is based on [Keep A Changelog](http://keepachangelog.com/)
- Import entries from a json file
- Create the wallets directory
## Changed
### Changed
- Prefix error message with ERROR
- Fix new line with clear input

View file

@ -1,7 +1,9 @@
# gpm: Go Passwords Manager
[![Version](https://img.shields.io/badge/latest_version-1.1.0-green.svg)](https://git.yaegashi.fr/nishiki/gpm/releases)
[![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](https://git.yaegashi.fr/nishiki/gpm/src/branch/master/LICENSE)
[![Version](https://img.shields.io/badge/latest_version-2.0.0-green.svg)](https://code.waks.be/nishiki/gpm/releases)
[![Build Status](https://travis-ci.org/nishiki/gpm.svg?branch=master)](https://travis-ci.org/nishiki/gpm)
[![GoReport](https://goreportcard.com/badge/code.waks.be/nishiki/gpm)](https://goreportcard.com/report/code.waks.be/nishiki/gpm)
[![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](https://code.waks.be/nishiki/gpm/src/branch/master/LICENSE)
gpm is passwords manager write in go and use AES-256 to encrypt the wallets
@ -19,81 +21,38 @@ gpm is passwords manager write in go and use AES-256 to encrypt the wallets
- Download and build
```text
go get git.yaegashi.fr/nishiki/gpm/cmd/gpm
go get code.waks.be/nishiki/gpm/cmd/gpm
```
## How to use
### First steps
### First launch
- Add new entry `gpm -add`
```text
Enter the passphrase to unlock the wallet:
Enter the name: Test
Enter the group: MyGroup
Enter the URI: http://localhost
Enter the username: lastname
Enter the new password:
Enter the OTP key:
Enter a comment: My first entry
the entry has been added
```
- Search and copy `gpm -copy`
```text
Enter the passphrase to unlock the wallet:
MyGroup
| NAME | URI | USER | OTP | COMMENT
----+------+------------------+----------+-----+-----------------
0 | Test | http://localhost | lastname | X | My first entry
select one action: p
select one action: l
select one action: q
```
- Run `gpm`
- Enter the passphrase to encrypt your new wallet
- Press `n` to create your first entry and follow the instructions
### All options
```text
gpm -help
-add
add a new entry in the wallet
-config string
specify the config file
-copy
enter an copy mode for an entry
-delete
delete an entry
-digit
use digit to generate a random password
-export
export a wallet in json format
-group string
search the entries in this group
-export string
json file path to export a wallet
-help
print this help message
-import string
import entries from a json file
json file path to import entries
-length int
specify the password length (default 16)
-letter
use letter to generate a random password
-list
list the entries in a wallet
-password
generate and print a random password
-pattern string
search the entries with this pattern
-random
generate a random password for a new entry or an update
-special
use special chars to generate a random password
-update
update an entry
-wallet string
specify the wallet
```

View file

@ -1,4 +0,0 @@
module git.yaegashi.fr/nishiki/gpm/cmd/gpm
require git.yaegashi.fr/nishiki/gpm/gpm v0.0.0
replace git.yaegashi.fr/nishiki/gpm/gpm => ../../gpm

17
go.mod Normal file
View file

@ -0,0 +1,17 @@
module code.waks.be/nishiki/gpm
go 1.23.1
require (
github.com/atotto/clipboard v0.1.4
github.com/gizak/termui/v3 v3.1.0
github.com/pquerna/otp v1.4.0
golang.org/x/crypto v0.28.0
)
require (
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/mattn/go-runewidth v0.0.2 // indirect
github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect
github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d // indirect
)

23
go.sum Normal file
View file

@ -0,0 +1,23 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gizak/termui/v3 v3.1.0 h1:ZZmVDgwHl7gR7elfKf1xc4IudXZ5qqfDh4wExk4Iajc=
github.com/gizak/termui/v3 v3.1.0/go.mod h1:bXQEBkJpzxUAKf0+xq9MSWAvWZlE7c+aidmyFlkYTrY=
github.com/mattn/go-runewidth v0.0.2 h1:UnlwIPBGaTZfPQ6T1IGzPI0EkYAQmT9fAEJ/poFC63o=
github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM=
github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d h1:x3S6kxmy49zXVVyhcnrFqxvNVCBPb2KZ9hV2RBdS840=
github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d/go.mod h1:IuKpRQcYE1Tfu+oAQqaLisqDeXgjyyltCfsaoYN18NQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=

View file

@ -1,302 +0,0 @@
// Copyright 2019 Adrien Waksberg
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package gpm
import(
"bufio"
"fmt"
"io/ioutil"
"os"
"strconv"
"syscall"
"github.com/atotto/clipboard"
"github.com/olekukonko/tablewriter"
"golang.org/x/crypto/ssh/terminal"
)
// Cli contain config and wallet to use
type Cli struct {
Config Config
Wallet Wallet
}
// printEntries show entries with tables
func (c *Cli) printEntries(entries []Entry) {
var otp string
var tables map[string]*tablewriter.Table
tables = make(map[string]*tablewriter.Table)
for i, entry := range entries {
if entry.OTP == "" { otp = "" } else { otp = "X" }
if _, present := tables[entry.Group]; present == false {
tables[entry.Group] = tablewriter.NewWriter(os.Stdout)
tables[entry.Group].SetHeader([]string{"", "Name", "URI", "User", "OTP", "Comment"})
tables[entry.Group].SetBorder(false)
tables[entry.Group].SetColumnColor(
tablewriter.Colors{tablewriter.Normal, tablewriter.FgYellowColor},
tablewriter.Colors{tablewriter.Normal, tablewriter.FgWhiteColor},
tablewriter.Colors{tablewriter.Normal, tablewriter.FgCyanColor},
tablewriter.Colors{tablewriter.Normal, tablewriter.FgGreenColor},
tablewriter.Colors{tablewriter.Normal, tablewriter.FgWhiteColor},
tablewriter.Colors{tablewriter.Normal, tablewriter.FgMagentaColor})
}
tables[entry.Group].Append([]string{ strconv.Itoa(i), entry.Name, entry.URI, entry.User, otp, entry.Comment })
}
for group, table := range tables {
fmt.Printf("\n%s\n\n", group)
table.Render()
fmt.Println("")
}
}
// error print a message and exit)
func (c *Cli) error(msg string) {
fmt.Printf("ERROR: %s\n", msg)
os.Exit(2)
}
// input from the console
func (c *Cli) input(text string, defaultValue string, show bool) string {
fmt.Print(text)
if show == false {
data, _ := terminal.ReadPassword(int(syscall.Stdin))
text := string(data)
fmt.Printf("\n")
if text == "" {
return defaultValue
}
return text
}
input := bufio.NewScanner(os.Stdin)
input.Scan()
if input.Text() == "" {
return defaultValue
}
return input.Text()
}
// selectEntry with a form
func (c *Cli) selectEntry() Entry {
var index int
entries := c.Wallet.SearchEntry(*PATTERN, *GROUP)
if len(entries) == 0 {
fmt.Println("no entry found")
os.Exit(1)
}
c.printEntries(entries)
if len(entries) == 1 {
return entries[0]
}
for true {
index, err := strconv.Atoi(c.input("Select the entry: ", "", true))
if err == nil && index >= 0 && index + 1 <= len(entries) {
break
}
fmt.Println("your choice is not an integer or is out of range")
}
return entries[index]
}
// loadWallet get and unlock the wallet
func (c *Cli) loadWallet() {
var walletName string
passphrase := c.input("Enter the passphrase to unlock the wallet: ", "", false)
if *WALLET == "" {
walletName = c.Config.WalletDefault
} else {
walletName = *WALLET
}
c.Wallet = Wallet{
Name: walletName,
Path: fmt.Sprintf("%s/%s.gpm", c.Config.WalletDir, walletName),
Passphrase: passphrase,
}
err := c.Wallet.Load()
if err != nil {
c.error(fmt.Sprintf("%s", err))
}
}
// List the entry of a wallet
func (c *Cli) listEntry() {
c.loadWallet()
entries := c.Wallet.SearchEntry(*PATTERN, *GROUP)
if len(entries) == 0 {
fmt.Println("no entry found")
os.Exit(1)
} else {
c.printEntries(entries)
}
}
// Delete an entry of a wallet
func (c *Cli) deleteEntry() {
var entry Entry
c.loadWallet()
entry = c.selectEntry()
confirm := c.input("are you sure you want to remove this entry [y/N] ?", "N", true)
if confirm == "y" {
err := c.Wallet.DeleteEntry(entry.ID)
if err != nil {
c.error(fmt.Sprintf("%s", err))
}
err = c.Wallet.Save()
if err != nil {
c.error(fmt.Sprintf("%s", err))
}
fmt.Println("the entry has been deleted")
}
}
// Add a new entry in wallet
func (c *Cli) addEntry() {
c.loadWallet()
entry := Entry{}
entry.GenerateID()
entry.Name = c.input("Enter the name: ", "", true)
entry.Group = c.input("Enter the group: ", "", true)
entry.URI = c.input("Enter the URI: ", "", true)
entry.User = c.input("Enter the username: ", "", true)
if *RANDOM {
entry.Password = RandomString(c.Config.PasswordLength,
c.Config.PasswordLetter, c.Config.PasswordDigit, c.Config.PasswordSpecial)
} else {
entry.Password = c.input("Enter the new password: ", entry.Password, false)
}
entry.OTP = c.input("Enter the OTP key: ", "", false)
entry.Comment = c.input("Enter a comment: ", "", true)
err := c.Wallet.AddEntry(entry)
if err != nil {
c.error(fmt.Sprintf("%s", err))
}
err = c.Wallet.Save()
if err != nil {
c.error(fmt.Sprintf("%s", err))
}
fmt.Println("the entry has been added")
}
// Update an entry in wallet
func (c *Cli) updateEntry() {
c.loadWallet()
entry := c.selectEntry()
entry.Name = c.input("Enter the new name: ", entry.Name, true)
entry.Group = c.input("Enter the new group: ", entry.Group, true)
entry.URI = c.input("Enter the new URI: ", entry.URI, true)
entry.User = c.input("Enter the new username: ", entry.User, true)
if *RANDOM {
entry.Password = RandomString(c.Config.PasswordLength,
c.Config.PasswordLetter, c.Config.PasswordDigit, c.Config.PasswordSpecial)
} else {
entry.Password = c.input("Enter the new password: ", entry.Password, false)
}
entry.OTP = c.input("Enter the new OTP key: ", entry.OTP, false)
entry.Comment = c.input("Enter a new comment: ", entry.Comment, true)
err := c.Wallet.UpdateEntry(entry)
if err != nil {
c.error(fmt.Sprintf("%s", err))
}
c.Wallet.Save()
}
// Copy login and password from an entry
func (c *Cli) copyEntry() {
c.loadWallet()
entry := c.selectEntry()
for true {
choice := c.input("select one action: ", "", true)
switch choice {
case "l":
clipboard.WriteAll(entry.User)
case "p":
clipboard.WriteAll(entry.Password)
case "o":
code, time, _ := entry.OTPCode()
fmt.Printf("this OTP code is available for %d seconds\n", time)
clipboard.WriteAll(code)
case "q":
os.Exit(0)
default:
fmt.Println("l -> copy login")
fmt.Println("p -> copy password")
fmt.Println("o -> copy OTP code")
fmt.Println("q -> quit")
}
}
}
// Import entries from json file
func (c *Cli) ImportWallet() {
c.loadWallet()
_, err := os.Stat(*IMPORT)
if err != nil {
c.error(fmt.Sprintf("%s", err))
}
data, err := ioutil.ReadFile(*IMPORT)
if err != nil {
c.error(fmt.Sprintf("%s", err))
}
err = c.Wallet.Import(data)
if err != nil {
c.error(fmt.Sprintf("%s", err))
}
err = c.Wallet.Save()
if err != nil {
c.error(fmt.Sprintf("%s", err))
}
fmt.Println("the import was successful")
}
// Export a wallet in json format
func (c *Cli) ExportWallet() {
c.loadWallet()
data, err := c.Wallet.Export()
if err != nil {
c.error(fmt.Sprintf("%s", err))
}
fmt.Println(data)
}

View file

@ -1,104 +0,0 @@
// Copyright 2019 Adrien Waksberg
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package gpm
import (
"encoding/json"
"fmt"
"io/ioutil"
"os"
"os/user"
"runtime"
)
// Config struct contain the config
type Config struct {
WalletDir string `json:"wallet_dir"`
WalletDefault string `json:"wallet_default"`
PasswordLength int `json:"password_length"`
PasswordLetter bool `json:"password_letter"`
PasswordDigit bool `json:"password_digit"`
PasswordSpecial bool `json:"password_special"`
}
// Init the configuration
func (c *Config) Init() error {
user, err := user.Current()
if err != nil {
return err
}
if runtime.GOOS == "darwin" {
c.WalletDir = fmt.Sprintf("%s/Library/Preferences/gpm", user.HomeDir)
} else if runtime.GOOS == "windows" {
c.WalletDir = fmt.Sprintf("%s/AppData/Local/gpm", user.HomeDir)
} else {
c.WalletDir = fmt.Sprintf("%s/.config/gpm", user.HomeDir)
}
c.WalletDefault = "default"
c.PasswordLength = 16
c.PasswordLetter = true
c.PasswordDigit = true
c.PasswordSpecial = false
return nil
}
// Load the configuration from a file
func (c *Config) Load(path string) error {
err := c.Init()
if err != nil {
return err
}
if path != "" {
_, err = os.Stat(path)
if err != nil {
}
data, err := ioutil.ReadFile(path)
if err != nil {
return err
}
err = json.Unmarshal(data, &c)
if err != nil {
return err
}
}
err = os.MkdirAll(c.WalletDir, 0700)
if err != nil {
return err
}
return nil
}
// Save the configuration
func (c *Config) Save(path string) error {
data, err := json.Marshal(&c)
if err != nil {
return err
}
err = ioutil.WriteFile(path, []byte(data), 0644)
if err != nil {
return err
}
return nil
}

View file

@ -1,105 +0,0 @@
// Copyright 2019 Adrien Waksberg
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package gpm
import(
"crypto/aes"
"crypto/sha512"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"io"
mrand "math/rand"
"time"
"golang.org/x/crypto/pbkdf2"
)
// Encrypt data with aes256
func Encrypt(data []byte, passphrase string, salt string) (string, error) {
key := pbkdf2.Key([]byte(passphrase), []byte(salt), 4096, 32, sha512.New)
block, err := aes.NewCipher([]byte(key))
if err != nil {
return "", err
}
cipher, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, cipher.NonceSize())
_, err = io.ReadFull(rand.Reader, nonce)
if err != nil {
return "", err
}
dataEncrypted := cipher.Seal(nonce, nonce, data, nil)
return base64.StdEncoding.EncodeToString(dataEncrypted), nil
}
// Decrypt data
func Decrypt(data string, passphrase string, salt string) ([]byte, error) {
key := pbkdf2.Key([]byte(passphrase), []byte(salt), 4096, 32, sha512.New)
rawData, err := base64.StdEncoding.DecodeString(data)
if err != nil {
return []byte{}, err
}
block, err := aes.NewCipher([]byte(key))
if err != nil {
return []byte{}, err
}
cipher, err := cipher.NewGCM(block)
if err != nil {
return []byte{}, err
}
nonceSize := cipher.NonceSize()
nonce, text := rawData[:nonceSize], rawData[nonceSize:]
plaintext, err := cipher.Open(nil, nonce, text, nil)
if err != nil {
return []byte{}, err
}
return plaintext, nil
}
// RandomString generate a random string
func RandomString(length int, letter bool, digit bool, special bool) string {
digits := "0123456789"
specials := "~=+%^*/()[]{}/!@#$?|"
letters := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
chars := ""
randomString := make([]byte, length)
if letter { chars = chars + letters }
if digit { chars = chars + digits }
if special { chars = chars + specials }
if !letter && !digit && !special {
chars = digits + letters
}
mrand.Seed(time.Now().UnixNano())
for i := 0; i < length; i++ {
randomString[i] = chars[mrand.Intn(len(chars))]
}
return string(randomString)
}

View file

@ -1 +0,0 @@
module git.yaegashi.fr/nishiki/gpm

View file

@ -1,71 +0,0 @@
// Copyright 2019 Adrien Waksberg
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package gpm
import(
"fmt"
"flag"
"os"
)
// Options
var(
ADD = flag.Bool("add", false, "add a new entry in the wallet")
UPDATE = flag.Bool("update", false, "update an entry")
DELETE = flag.Bool("delete", false, "delete an entry")
LIST = flag.Bool("list", false, "list the entries in a wallet")
LENGTH = flag.Int("length", 16, "specify the password length")
COPY = flag.Bool("copy", false, "enter an copy mode for an entry")
CONFIG = flag.String("config", "", "specify the config file")
GROUP = flag.String("group", "", "search the entries in this group ")
WALLET = flag.String("wallet", "", "specify the wallet")
PATTERN = flag.String("pattern", "", "search the entries with this pattern")
RANDOM = flag.Bool("random", false, "generate a random password for a new entry or an update")
PASSWD = flag.Bool("password", false, "generate and print a random password")
DIGIT = flag.Bool("digit", false, "use digit to generate a random password")
LETTER = flag.Bool("letter", false, "use letter to generate a random password")
SPECIAL = flag.Bool("special", false, "use special chars to generate a random password")
EXPORT = flag.Bool("export", false, "export a wallet in json format")
IMPORT = flag.String("import", "", "import entries from a json file")
HELP = flag.Bool("help", false, "print this help message")
)
// Run the cli interface
func Run() {
var cli Cli
cli.Config.Load(*CONFIG)
flag.Parse()
if *HELP {
flag.PrintDefaults()
os.Exit(1)
} else if *PASSWD {
fmt.Println(RandomString(*LENGTH, *LETTER, *DIGIT, *SPECIAL))
} else if *LIST {
cli.listEntry()
} else if *COPY {
cli.copyEntry()
} else if *ADD {
cli.addEntry()
} else if *UPDATE {
cli.updateEntry()
} else if *DELETE {
cli.deleteEntry()
} else if *IMPORT != "" {
cli.ImportWallet()
} else if *EXPORT {
cli.ExportWallet()
}
}

View file

@ -1,220 +0,0 @@
// Copyright 2019 Adrien Waksberg
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package gpm
import (
"encoding/json"
"fmt"
"io/ioutil"
"math/rand"
"os"
"regexp"
"sort"
"strings"
)
// WalletFile contains the data in file
type WalletFile struct {
Salt string
Data string
}
// Wallet struct have wallet informations
type Wallet struct {
Name string
Path string
Salt string
Passphrase string
Entries []Entry
}
// Load all wallet's Entrys from the disk
func (w *Wallet) Load() error {
var walletFile WalletFile
_, err := os.Stat(w.Path)
if err != nil {
return nil
}
content, err := ioutil.ReadFile(w.Path)
if err != nil {
return err
}
err = json.Unmarshal(content, &walletFile)
if err != nil {
return err
}
w.Salt = walletFile.Salt
data, err := Decrypt(string(walletFile.Data), w.Passphrase, w.Salt)
if err != nil {
return err
}
err = json.Unmarshal(data, &w.Entries)
if err != nil {
return err
}
return nil
}
// Save the wallet on the disk
func (w *Wallet) Save() error {
if w.Salt == "" {
salt := make([]byte, 8)
for i := 0; i < 8; i++ {
salt[i] = byte(65 + rand.Intn(25))
}
w.Salt = string(salt)
}
data, err := json.Marshal(&w.Entries)
if err != nil {
return err
}
dataEncrypted, err := Encrypt(data, w.Passphrase, w.Salt)
if err != nil {
return err
}
walletFile := WalletFile{ Salt: w.Salt, Data: dataEncrypted }
content, err := json.Marshal(&walletFile)
if err != nil {
return err
}
err = ioutil.WriteFile(w.Path, content, 0600)
if err != nil {
return err
}
return nil
}
// SearchEntry return an array with the array expected with the pattern
func (w *Wallet) SearchEntry(pattern string, group string) []Entry {
var entries []Entry
r := regexp.MustCompile(pattern)
for _, entry := range w.Entries {
if group != "" && strings.ToLower(entry.Group) != strings.ToLower(group) {
continue
}
if r.Match([]byte(entry.Name)) || r.Match([]byte(entry.Comment)) || r.Match([]byte(entry.URI)) {
entries = append(entries, entry)
}
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].Group < entries[j].Group
})
return entries
}
// SearchEntryByID return an Entry
func (w *Wallet) SearchEntryByID(id string) Entry {
for _, entry := range w.Entries {
if entry.ID == id {
return entry
}
}
return Entry{}
}
// AddEntry append a new entry to wallet
func (w *Wallet) AddEntry(entry Entry) error {
err := entry.Verify()
if err != nil {
return err
}
if w.SearchEntryByID(entry.ID) != (Entry{}) {
return fmt.Errorf("the id already exists in wallet, can't add the entry")
}
w.Entries = append(w.Entries, entry)
return nil
}
// DeleteEntry delete an entry to wallet
func (w *Wallet) DeleteEntry(id string) error {
for index, entry := range w.Entries {
if entry.ID == id {
w.Entries = append(w.Entries[:index], w.Entries[index+1:]...)
return nil
}
}
return fmt.Errorf("entry not found with this id")
}
// UpdateEntry update an Entry to wallet
func (w *Wallet) UpdateEntry(entry Entry) error {
oldEntry := w.SearchEntryByID(entry.ID)
if oldEntry == (Entry{}) {
return fmt.Errorf("entry not found with this id")
}
err := entry.Verify()
if err != nil {
return err
}
for index, i := range w.Entries {
if entry.ID == i.ID {
w.Entries[index] = entry
return nil
}
}
return fmt.Errorf("unknown error during the update")
}
// Import a wallet from a json string
func (w *Wallet) Import(data []byte) error {
var entries []Entry
err := json.Unmarshal([]byte(data), &entries)
if err != nil {
return err
}
for _, entry := range entries {
err = w.AddEntry(entry)
if err != nil {
return err
}
}
return nil
}
// Export a wallet to json
func (w *Wallet) Export() (string, error) {
data, err := json.Marshal(&w.Entries)
if err != nil {
return "", err
}
return string(data), nil
}

565
internal/gpm/cli.go Normal file
View file

@ -0,0 +1,565 @@
package gpm
import (
"fmt"
"flag"
"io/ioutil"
"os"
"time"
ui "github.com/gizak/termui/v3"
"github.com/gizak/termui/v3/widgets"
"github.com/atotto/clipboard"
)
// Options
var (
LENGTH = flag.Int("length", 16, "specify the password length")
CONFIG = flag.String("config", "", "specify the config file")
WALLET = flag.String("wallet", "", "specify the wallet")
PASSWD = flag.Bool("password", false, "generate and print a random password")
DIGIT = flag.Bool("digit", false, "use digit to generate a random password")
LETTER = flag.Bool("letter", false, "use letter to generate a random password")
SPECIAL = flag.Bool("special", false, "use special chars to generate a random password")
EXPORT = flag.String("export", "", "json file path to export a wallet")
IMPORT = flag.String("import", "", "json file path to import entries")
HELP = flag.Bool("help", false, "print this help message")
)
// Cli struct
type Cli struct {
Config Config
Wallet Wallet
}
// NotificationBox print a notification
func (c *Cli) NotificationBox(msg string, error bool) {
p := widgets.NewParagraph()
p.SetRect(25, 20, 80, 23)
if error {
p.Title = "Error"
p.Text = fmt.Sprintf("[%s](fg:red) ", msg)
} else {
p.Title = "Notification"
p.Text = fmt.Sprintf("[%s](fg:green) ", msg)
}
ui.Render(p)
}
// ChoiceBox is a boolean form
func (c *Cli) ChoiceBox(title string, choice bool) bool {
t := widgets.NewTabPane("Yes", "No")
t.SetRect(10, 10, 70, 5)
t.Title = title
t.Border = true
if !choice {
t.ActiveTabIndex = 1
}
uiEvents := ui.PollEvents()
for {
ui.Render(t)
e := <-uiEvents
switch e.ID {
case "<Enter>":
return choice
case "<Left>", "h":
t.FocusLeft()
choice = true
case "<Right>", "l":
t.FocusRight()
choice = false
}
}
}
// InputBox is string form
func (c *Cli) InputBox(title string, input string, hidden bool) string {
var secret string
p := widgets.NewParagraph()
p.SetRect(10, 10, 70, 5)
p.Title = title
p.Text = input
uiEvents := ui.PollEvents()
for {
ui.Render(p)
e := <-uiEvents
switch e.ID {
case "q", "<C-c>":
return ""
case "<Backspace>":
if len(input) >= 1 {
input = input[:len(input)-1]
}
case "<Enter>":
return input
case "<Space>":
input = input + " "
default:
input = input + e.ID
}
if hidden {
secret = ""
for i := 1; i <= int(float64(len(input)) * 1.75); i++ {
secret = secret + "*"
}
p.Text = secret
} else {
p.Text = input
}
}
}
// SelectBox to select an item from a list
func (c *Cli) SelectBox(title string, items []string) string {
l := widgets.NewList()
l.Title = title
l.TextStyle = ui.NewStyle(ui.ColorYellow)
l.SelectedRowStyle = ui.NewStyle(ui.ColorGreen, ui.ColorClear, ui.ModifierBold)
l.WrapText = false
l.SetRect(10, 10, 70, 5)
l.Rows = items
uiEvents := ui.PollEvents()
for {
ui.Render(l)
e := <-uiEvents
switch e.ID {
case "q", "<C-c>", "<Escape>":
return ""
case "<Enter>":
if len(l.Rows) == 0 {
return ""
}
return l.Rows[l.SelectedRow]
case "j", "<Down>":
if len(l.Rows) > 0 {
l.ScrollDown()
}
case "k", "<Up>":
if len(l.Rows) > 0 {
l.ScrollUp()
}
}
}
}
// EntryBox to add a new entry
func (c *Cli) EntryBox(entry Entry) {
p := widgets.NewParagraph()
p.SetRect(25, 0, 80, 20)
p.Text = fmt.Sprintf("%s[Name:](fg:yellow) %s\n", p.Text, entry.Name)
p.Text = fmt.Sprintf("%s[Group:](fg:yellow) %s\n", p.Text, entry.Group)
p.Text = fmt.Sprintf("%s[URI:](fg:yellow) %s\n", p.Text, entry.URI)
p.Text = fmt.Sprintf("%s[User:](fg:yellow) %s\n", p.Text, entry.User)
if entry.OTP == "" {
p.Text = fmt.Sprintf("%s[OTP:](fg:yellow) [no](fg:red)\n", p.Text)
} else {
p.Text = fmt.Sprintf("%s[OTP:](fg:yellow) [yes](fg:green)\n", p.Text)
}
p.Text = fmt.Sprintf("%s[Comment:](fg:yellow) %v\n", p.Text, entry.Comment)
ui.Render(p)
}
// GroupsBox to select a group
func (c *Cli) GroupsBox() string {
l := widgets.NewList()
l.Title = "Groups"
l.TextStyle = ui.NewStyle(ui.ColorYellow)
l.SelectedRowStyle = ui.NewStyle(ui.ColorGreen, ui.ColorClear, ui.ModifierBold)
l.WrapText = false
l.SetRect(25, 0, 80, 20)
l.Rows = append(c.Wallet.Groups(), "No group")
uiEvents := ui.PollEvents()
for {
ui.Render(l)
e := <-uiEvents
switch e.ID {
case "q", "<C-c>", "<Escape>":
return ""
case "<Enter>":
if len(l.Rows) == 0 {
return ""
}
return l.Rows[l.SelectedRow]
case "j", "<Down>":
if len(l.Rows) > 0 {
l.ScrollDown()
}
case "k", "<Up>":
if len(l.Rows) > 0 {
l.ScrollUp()
}
}
}
}
// HelpBox print help message
func (c *Cli) HelpBox() {
p := widgets.NewParagraph()
p.SetRect(25, 0, 80, 20)
p.Title = "Short cuts"
p.Text = `[<escape>](fg:yellow) clear current search
[<enter> ](fg:yellow) select entry or group
[<up> ](fg:yellow) move cursor up
[<down> ](fg:yellow) move cursor down
[h ](fg:yellow) print this help message
[q ](fg:yellow) quit
[g ](fg:yellow) filter the entries by group
[n ](fg:yellow) add a new entry
[u ](fg:yellow) update an entry
[d ](fg:yellow) delete an entry
[/ ](fg:yellow) search
[Ctrl + b](fg:yellow) copy username
[Ctrl + c](fg:yellow) copy password
[Ctrl + o](fg:yellow) copy OTP code
`
ui.Render(p)
}
// UnlockWallet to decrypt a wallet
func (c *Cli) UnlockWallet(wallet string) error {
var walletName string
var err error
ui.Clear()
if wallet == "" {
walletName = c.Config.WalletDefault
} else {
walletName = wallet
}
c.Wallet = Wallet{
Name: walletName,
Path: fmt.Sprintf("%s/%s.gpm", c.Config.WalletDir, walletName),
}
for i := 0; i < 3; i++ {
c.Wallet.Passphrase = c.InputBox("Passphrase to unlock the wallet", "", true)
err = c.Wallet.Load()
if err == nil {
return nil
}
c.NotificationBox(fmt.Sprintf("%s", err), true)
}
return err
}
// DeleteEntry to delete an exisiting entry
func (c *Cli) DeleteEntry(entry Entry) bool {
if !c.ChoiceBox("Do you want delete this entry ?", false) {
return false
}
err := c.Wallet.DeleteEntry(entry.ID)
if err != nil {
c.NotificationBox(fmt.Sprintf("%s", err), true)
return false
}
err = c.Wallet.Save()
if err != nil {
c.NotificationBox(fmt.Sprintf("%s", err), true)
return false
}
return true
}
// UpdateEntry to update an existing entry
func (c *Cli) UpdateEntry(entry Entry) bool {
entry.Name = c.InputBox("Name", entry.Name, false)
if entry.Group == "" || c.ChoiceBox("Change the group ?", false) {
group := c.SelectBox("Group", append(c.Wallet.Groups(), "* Create new group *"))
if group == "* Create new group *" || group == "" {
entry.Group = c.InputBox("Group", "", false)
} else {
entry.Group = group
}
}
entry.URI = c.InputBox("URI", entry.URI, false)
entry.User = c.InputBox("Username", entry.User, false)
if c.ChoiceBox("Generate a new random password ?", false) {
entry.Password = RandomString(c.Config.PasswordLength,
c.Config.PasswordLetter, c.Config.PasswordDigit, c.Config.PasswordSpecial)
} else {
entry.Password = c.InputBox("Password", "", true)
}
entry.OTP = c.InputBox("OTP Key", entry.OTP, false)
entry.Comment = c.InputBox("Comment", entry.Comment, false)
err := c.Wallet.UpdateEntry(entry)
if err != nil {
c.NotificationBox(fmt.Sprintf("%s", err), true)
return false
}
err = c.Wallet.Save()
if err != nil {
c.NotificationBox(fmt.Sprintf("%s", err), true)
return false
}
return true
}
// AddEntry to add new entry
func (c *Cli) AddEntry() bool {
entry := Entry{}
entry.GenerateID()
entry.Name = c.InputBox("Name", "", false)
group := c.SelectBox("Group", append(c.Wallet.Groups(), "* Create new group *"))
if group == "* Create new group *" || group == "" {
entry.Group = c.InputBox("Group", "", false)
} else {
entry.Group = group
}
entry.URI = c.InputBox("URI", "", false)
entry.User = c.InputBox("Username", "", false)
if c.ChoiceBox("Generate a random password ?", true) {
entry.Password = RandomString(c.Config.PasswordLength,
c.Config.PasswordLetter, c.Config.PasswordDigit, c.Config.PasswordSpecial)
} else {
entry.Password = c.InputBox("Password", "", true)
}
entry.OTP = c.InputBox("OTP Key", "", false)
entry.Comment = c.InputBox("Comment", "", false)
err := c.Wallet.AddEntry(entry)
if err != nil {
c.NotificationBox(fmt.Sprintf("%s", err), true)
return false
}
err = c.Wallet.Save()
if err != nil {
c.NotificationBox(fmt.Sprintf("%s", err), true)
return false
}
return true
}
// ListEntries to list all entries
func (c *Cli) ListEntries(ch chan<- bool) {
var pattern, group string
var entries []Entry
var selected bool
refresh := true
noGroup := false
index := -1
l := widgets.NewList()
l.TextStyle = ui.NewStyle(ui.ColorYellow)
l.SelectedRowStyle = ui.NewStyle(ui.ColorGreen, ui.ColorClear, ui.ModifierBold)
l.WrapText = false
l.SetRect(0, 0, 25, 23)
ui.Clear()
uiEvents := ui.PollEvents()
for {
if group != "" {
l.Title = fmt.Sprintf("Group: %s", group)
} else if noGroup {
l.Title = "Group: No group"
} else {
l.Title = "Group: All"
}
if refresh {
refresh = false
index = -1
entries = c.Wallet.SearchEntry(pattern, group, noGroup)
l.Rows = []string{}
for _, entry := range entries {
l.Rows = append(l.Rows, entry.Name)
}
ui.Clear()
c.NotificationBox("press h to view short cuts", false)
}
if len(entries) > 0 && index >= 0 && index < len(entries) {
selected = true
} else {
selected = false
}
if selected {
c.EntryBox(entries[index])
}
ui.Render(l)
e := <-uiEvents
switch e.ID {
case "h":
c.HelpBox()
index = -1
case "q":
clipboard.WriteAll("")
ch <- true
case "<Enter>":
index = l.SelectedRow
case "<Escape>":
pattern = ""
group = "all"
refresh = true
case "n":
refresh = c.AddEntry()
case "u":
if selected {
refresh = c.UpdateEntry(entries[index])
}
case "d":
if selected {
refresh = c.DeleteEntry(entries[index])
}
case "/":
pattern = c.InputBox("Search", pattern, false)
refresh = true
case "g":
group = c.GroupsBox()
noGroup = false
if group == "No group" {
group = ""
noGroup = true
}
refresh = true
case "j", "<Down>":
if len(entries) > 0 {
l.ScrollDown()
}
case "k", "<Up>":
if len(entries) > 0 {
l.ScrollUp()
}
case "<C-b>":
if selected {
clipboard.WriteAll(entries[index].User)
c.NotificationBox("the username is copied in clipboard", false)
}
case "<C-c>":
if selected {
c.NotificationBox("the password is copied in clipboard", false)
clipboard.WriteAll(entries[index].Password)
}
case "<C-o>":
if selected {
code, time, _ := entries[index].OTPCode()
c.NotificationBox(fmt.Sprintf("the OTP code is available for %d seconds", time), false)
clipboard.WriteAll(code)
}
}
uiEvents = ui.PollEvents()
ch <- false
}
}
// ImportWallet import entries from json file
func (c *Cli) ImportWallet() error {
_, err := os.Stat(*IMPORT)
if err != nil {
return err
}
data, err := ioutil.ReadFile(*IMPORT)
if err != nil {
return err
}
err = c.Wallet.Import(data)
if err != nil {
return err
}
err = c.Wallet.Save()
if err != nil {
return err
}
return nil
}
// ExportWallet export a wallet in json format
func (c *Cli) ExportWallet() error {
data, err := c.Wallet.Export()
if err != nil {
return err
}
err = ioutil.WriteFile(*EXPORT, data, 0600)
if err != nil {
return err
}
return nil
}
// Run the cli interface
func Run() {
var c Cli
flag.Parse()
c.Config.Load(*CONFIG)
if *HELP {
flag.PrintDefaults()
os.Exit(1)
} else if *PASSWD {
fmt.Println(RandomString(*LENGTH, *LETTER, *DIGIT, *SPECIAL))
os.Exit(0)
}
if err := ui.Init(); err != nil {
fmt.Printf("failed to initialize termui: %v\n", err)
os.Exit(2)
}
defer ui.Close()
err := c.UnlockWallet(*WALLET)
if err != nil {
ui.Close()
fmt.Printf("failed to open the wallet: %v\n", err)
os.Exit(2)
}
if *IMPORT != "" {
err := c.ImportWallet()
if err != nil {
ui.Close()
fmt.Printf("failed to import: %v\n", err)
os.Exit(2)
}
} else if *EXPORT != "" {
err := c.ExportWallet()
if err != nil {
ui.Close()
fmt.Printf("failed to export: %v\n", err)
os.Exit(2)
}
} else {
c1 := make(chan bool)
go c.ListEntries(c1)
for {
select {
case res := <-c1:
if res {
return
}
case <-time.After(300 * time.Second):
return
}
}
}
}

104
internal/gpm/config.go Normal file
View file

@ -0,0 +1,104 @@
// Copyright 2019 Adrien Waksberg
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package gpm
import (
"encoding/json"
"fmt"
"io/ioutil"
"os"
"os/user"
"runtime"
)
// Config struct contain the config
type Config struct {
WalletDir string `json:"wallet_dir"`
WalletDefault string `json:"wallet_default"`
PasswordLength int `json:"password_length"`
PasswordLetter bool `json:"password_letter"`
PasswordDigit bool `json:"password_digit"`
PasswordSpecial bool `json:"password_special"`
}
// Init the configuration
func (c *Config) Init() error {
user, err := user.Current()
if err != nil {
return err
}
if runtime.GOOS == "darwin" {
c.WalletDir = fmt.Sprintf("%s/Library/Preferences/gpm", user.HomeDir)
} else if runtime.GOOS == "windows" {
c.WalletDir = fmt.Sprintf("%s/AppData/Local/gpm", user.HomeDir)
} else {
c.WalletDir = fmt.Sprintf("%s/.config/gpm", user.HomeDir)
}
c.WalletDefault = "default"
c.PasswordLength = 16
c.PasswordLetter = true
c.PasswordDigit = true
c.PasswordSpecial = false
return nil
}
// Load the configuration from a file
func (c *Config) Load(path string) error {
err := c.Init()
if err != nil {
return err
}
if path != "" {
_, err = os.Stat(path)
if err != nil {
}
data, err := ioutil.ReadFile(path)
if err != nil {
return err
}
err = json.Unmarshal(data, &c)
if err != nil {
return err
}
}
err = os.MkdirAll(c.WalletDir, 0700)
if err != nil {
return err
}
return nil
}
// Save the configuration
func (c *Config) Save(path string) error {
data, err := json.Marshal(&c)
if err != nil {
return err
}
err = ioutil.WriteFile(path, []byte(data), 0644)
if err != nil {
return err
}
return nil
}

View file

@ -0,0 +1,72 @@
package gpm
import (
"io/ioutil"
"os"
"testing"
)
func TestInit(t *testing.T) {
var config Config
err := config.Init()
if err != nil {
t.Error("the config init mustn't return an error")
}
if config.WalletDefault != "default" {
t.Errorf("the WalletDefaut must be 'default': %s", config.WalletDefault)
}
if config.PasswordLength != 16 {
t.Errorf("the PasswordLength must be 16: %d", config.PasswordLength)
}
if config.PasswordLetter != true {
t.Error("the PasswordLetter must be true")
}
if config.PasswordDigit != true {
t.Error("the PasswordDigit must be true")
}
if config.PasswordSpecial != false {
t.Error("the PasswordSpecial must be false")
}
}
func TestSave(t *testing.T) {
var config Config
tmpFile, _ := ioutil.TempFile(os.TempDir(), "gpm_test-")
defer os.Remove(tmpFile.Name())
config.Init()
err := config.Save(tmpFile.Name())
if err != nil {
t.Errorf("save config mustn't return an error: %s", err)
}
}
func TestLoadWithFile(t *testing.T) {
var config Config
tmpFile, _ := ioutil.TempFile(os.TempDir(), "gpm_test-")
defer os.Remove(tmpFile.Name())
config.Init()
config.Save(tmpFile.Name())
err := config.Load(tmpFile.Name())
if err != nil {
t.Errorf("load config with file mustn't return an error: %s", err)
}
}
func TestLoadWithoutFile(t *testing.T) {
var config Config
err := config.Load("")
if err != nil {
t.Errorf("load config without file mustn't return an error: %s", err)
}
}

111
internal/gpm/crypto.go Normal file
View file

@ -0,0 +1,111 @@
// Copyright 2019 Adrien Waksberg
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package gpm
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha512"
"encoding/base64"
"io"
mrand "math/rand"
"time"
"golang.org/x/crypto/pbkdf2"
)
// Encrypt data with aes256
func Encrypt(data []byte, passphrase string, salt string) (string, error) {
key := pbkdf2.Key([]byte(passphrase), []byte(salt), 4096, 32, sha512.New)
block, err := aes.NewCipher([]byte(key))
if err != nil {
return "", err
}
cipher, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, cipher.NonceSize())
_, err = io.ReadFull(rand.Reader, nonce)
if err != nil {
return "", err
}
dataEncrypted := cipher.Seal(nonce, nonce, data, nil)
return base64.StdEncoding.EncodeToString(dataEncrypted), nil
}
// Decrypt data
func Decrypt(data string, passphrase string, salt string) ([]byte, error) {
key := pbkdf2.Key([]byte(passphrase), []byte(salt), 4096, 32, sha512.New)
rawData, err := base64.StdEncoding.DecodeString(data)
if err != nil {
return []byte{}, err
}
block, err := aes.NewCipher([]byte(key))
if err != nil {
return []byte{}, err
}
cipher, err := cipher.NewGCM(block)
if err != nil {
return []byte{}, err
}
nonceSize := cipher.NonceSize()
nonce, text := rawData[:nonceSize], rawData[nonceSize:]
plaintext, err := cipher.Open(nil, nonce, text, nil)
if err != nil {
return []byte{}, err
}
return plaintext, nil
}
// RandomString generate a random string
func RandomString(length int, letter bool, digit bool, special bool) string {
digits := "0123456789"
specials := "~=+%^*/()[]{}/!@#$?|"
letters := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
chars := ""
randomString := make([]byte, length)
if letter {
chars = chars + letters
}
if digit {
chars = chars + digits
}
if special {
chars = chars + specials
}
if !letter && !digit && !special {
chars = digits + letters
}
mrand.Seed(time.Now().UnixNano())
for i := 0; i < length; i++ {
randomString[i] = chars[mrand.Intn(len(chars))]
}
return string(randomString)
}

View file

@ -0,0 +1,91 @@
package gpm
import (
"regexp"
"testing"
)
func TestEncrypt(t *testing.T) {
secret := []byte("secret data")
data, err := Encrypt(secret, "passphrase", "salt")
if err != nil {
t.Errorf("Encrypt mustn't return an error: %s", err)
}
if data == "" {
t.Error("Encrypt must generate a string not empty")
}
}
func TestDecrypt(t *testing.T) {
secret := "secret data"
dataEncrypted, _ := Encrypt([]byte(secret), "passphrase", "salt")
data, err := Decrypt(dataEncrypted, "passphrase", "salt")
if err != nil {
t.Errorf("Decrypt mustn't return an error: %s", err)
}
if string(data) != secret {
t.Errorf("the encrypted secret is different of decrypted secret: %s", data)
}
}
func TestDecryptWithBadPassphrase(t *testing.T) {
secret := []byte("secret data")
dataEncrypted, _ := Encrypt(secret, "passphrase", "salt")
_, err := Decrypt(dataEncrypted, "bad", "salt")
if err == nil {
t.Error("Decrypt must return an error with bad passphrase")
}
}
func TestDecryptWithBadSalt(t *testing.T) {
secret := []byte("secret data")
dataEncrypted, _ := Encrypt(secret, "passphrase", "salt")
_, err := Decrypt(dataEncrypted, "passphrase", "bad")
if err == nil {
t.Error("Decrypt must return an error with bad salt")
}
}
func TestRandomStringLength(t *testing.T) {
password := RandomString(64, false, false, false)
if len(password) != 64 {
t.Errorf("the string must have 64 chars: %d", len(password))
}
r := regexp.MustCompile(`^[a-zA-Z0-9]{64}$`)
match := r.FindSubmatch([]byte(password))
if len(match) == 0 {
t.Errorf("the string must contain only digit and alphabetic characters: %s", password)
}
}
func TestRandomStringOnlyDigit(t *testing.T) {
password := RandomString(64, false, true, false)
r := regexp.MustCompile(`^[0-9]{64}$`)
match := r.FindSubmatch([]byte(password))
if len(match) == 0 {
t.Errorf("the string must contain only digit characters: %s", password)
}
}
func TestRandomStringOnlyAlphabetic(t *testing.T) {
password := RandomString(64, true, false, false)
r := regexp.MustCompile(`^[a-zA-Z]{64}$`)
match := r.FindSubmatch([]byte(password))
if len(match) == 0 {
t.Errorf("the string must contain only alphabetic characters: %s", password)
}
}
func TestRandomStringOnlySpecial(t *testing.T) {
password := RandomString(64, false, false, true)
r := regexp.MustCompile(`^[\~\=\+\%\^\*\/\(\)\[\]\{\}\!\@\#\$\?\|]{64}$`)
match := r.FindSubmatch([]byte(password))
if len(match) == 0 {
t.Errorf("the string must contain only alphabetic characters: %s", password)
}
}

View file

@ -16,22 +16,24 @@ package gpm
import (
"fmt"
"time"
"net/url"
"time"
"github.com/pquerna/otp/totp"
)
// Entry struct have the password informations
type Entry struct {
Name string `yaml:"name"`
ID string `yaml:"id"`
URI string `yaml:"uri"`
User string `yaml:"login"`
Password string `yaml:"password"`
OTP string `yaml:"otp"`
Group string `yaml:"group"`
Comment string `yaml:"comment"`
Name string
ID string
URI string
User string
Password string
OTP string
Group string
Comment string
Create int64
LastUpdate int64
}
// Verify if the item have'nt error

View file

@ -0,0 +1,65 @@
package gpm
import "testing"
func TestCreateEmptyEntry(t *testing.T) {
var entry Entry
err := entry.Verify()
if err == nil {
t.Error("an entry Without an ID must return an error")
}
}
func TestCreateEntryWithoutName(t *testing.T) {
var entry Entry
entry.GenerateID()
if entry.ID == "" {
t.Error("generateID can't be generate a void ID")
}
err := entry.Verify()
if err == nil {
t.Error("an entry without a name must return an error")
}
}
func TestCreateEntryWithName(t *testing.T) {
entry := Entry{Name: "test"}
entry.GenerateID()
err := entry.Verify()
if err != nil {
t.Errorf("an entry with a name mustn't return an error: %s", err)
}
}
func TestCreateEntryWithBadURI(t *testing.T) {
entry := Entry{Name: "test", URI: "url/bad:"}
entry.GenerateID()
err := entry.Verify()
if err == nil {
t.Error("an entry with a bad URI must return an error")
}
}
func TestCreateEntryWithGoodURI(t *testing.T) {
entry := Entry{Name: "test", URI: "http://localhost:8081"}
entry.GenerateID()
err := entry.Verify()
if err != nil {
t.Errorf("an entry with a good URI mustn't return an error: %s", err)
}
}
func TestGenerateOTPCode(t *testing.T) {
entry := Entry{OTP: "JBSWY3DPEHPK3PXP"}
code, time, err := entry.OTPCode()
if err != nil {
t.Errorf("must generate an OTP code without error: %s", err)
}
if len(code) != 6 {
t.Errorf("must generate an OTP code with 6 chars: %s", code)
}
if time < 0 || time > 30 {
t.Errorf("time must be between 0 and 30: %d", time)
}
}

241
internal/gpm/wallet.go Normal file
View file

@ -0,0 +1,241 @@
// Copyright 2019 Adrien Waksberg
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package gpm
import (
"encoding/json"
"fmt"
"io/ioutil"
"os"
"regexp"
"sort"
"strings"
"time"
)
// WalletFile contains the data in file
type WalletFile struct {
Salt string
Data string
}
// Wallet struct have wallet informations
type Wallet struct {
Name string
Path string
Salt string
Passphrase string
Entries []Entry
}
// Load all wallet's Entrys from the disk
func (w *Wallet) Load() error {
var walletFile WalletFile
_, err := os.Stat(w.Path)
if err != nil {
return nil
}
content, err := ioutil.ReadFile(w.Path)
if err != nil {
return err
}
err = json.Unmarshal(content, &walletFile)
if err != nil {
return err
}
w.Salt = walletFile.Salt
data, err := Decrypt(string(walletFile.Data), w.Passphrase, w.Salt)
if err != nil {
return err
}
err = json.Unmarshal(data, &w.Entries)
if err != nil {
return err
}
return nil
}
// Save the wallet on the disk
func (w *Wallet) Save() error {
if w.Salt == "" {
w.Salt = RandomString(12, true, true, false)
}
data, err := json.Marshal(&w.Entries)
if err != nil {
return err
}
dataEncrypted, err := Encrypt(data, w.Passphrase, w.Salt)
if err != nil {
return err
}
walletFile := WalletFile{Salt: w.Salt, Data: dataEncrypted}
content, err := json.Marshal(&walletFile)
if err != nil {
return err
}
err = ioutil.WriteFile(w.Path, content, 0600)
if err != nil {
return err
}
return nil
}
// Groups return array with the groups name
func (w *Wallet) Groups() []string {
var groups []string
var exist bool
for _, entry := range w.Entries {
exist = false
for _, group := range groups {
if group == entry.Group {
exist = true
break
}
}
if exist == false && entry.Group != "" {
groups = append(groups, entry.Group)
}
}
return groups
}
// SearchEntry return an array with the array expected with the pattern
func (w *Wallet) SearchEntry(pattern string, group string, noGroup bool) []Entry {
var entries []Entry
r := regexp.MustCompile(strings.ToLower(pattern))
for _, entry := range w.Entries {
if (noGroup && entry.Group != "") || (!noGroup && group != "" && strings.ToLower(entry.Group) != strings.ToLower(group)) {
continue
}
if r.Match([]byte(strings.ToLower(entry.Name))) ||
r.Match([]byte(strings.ToLower(entry.Comment))) || r.Match([]byte(strings.ToLower(entry.URI))) {
entries = append(entries, entry)
}
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].Group < entries[j].Group
})
return entries
}
// SearchEntryByID return an Entry
func (w *Wallet) SearchEntryByID(id string) Entry {
for _, entry := range w.Entries {
if entry.ID == id {
return entry
}
}
return Entry{}
}
// AddEntry append a new entry to wallet
func (w *Wallet) AddEntry(entry Entry) error {
err := entry.Verify()
if err != nil {
return err
}
if w.SearchEntryByID(entry.ID) != (Entry{}) {
return fmt.Errorf("the id already exists in wallet, can't add the entry")
}
entry.Create = time.Now().Unix()
entry.LastUpdate = entry.Create
w.Entries = append(w.Entries, entry)
return nil
}
// DeleteEntry delete an entry to wallet
func (w *Wallet) DeleteEntry(id string) error {
for index, entry := range w.Entries {
if entry.ID == id {
w.Entries = append(w.Entries[:index], w.Entries[index+1:]...)
return nil
}
}
return fmt.Errorf("entry not found with this id")
}
// UpdateEntry update an Entry to wallet
func (w *Wallet) UpdateEntry(entry Entry) error {
oldEntry := w.SearchEntryByID(entry.ID)
if oldEntry == (Entry{}) {
return fmt.Errorf("entry not found with this id")
}
err := entry.Verify()
if err != nil {
return err
}
entry.LastUpdate = time.Now().Unix()
for index, i := range w.Entries {
if entry.ID == i.ID {
w.Entries[index] = entry
return nil
}
}
return fmt.Errorf("unknown error during the update")
}
// Import a wallet from a json string
func (w *Wallet) Import(data []byte) error {
var entries []Entry
err := json.Unmarshal([]byte(data), &entries)
if err != nil {
return err
}
for _, entry := range entries {
err = w.AddEntry(entry)
if err != nil {
return err
}
}
return nil
}
// Export a wallet to json
func (w *Wallet) Export() ([]byte, error) {
data, err := json.Marshal(&w.Entries)
if err != nil {
return []byte{}, err
}
return data, nil
}

246
internal/gpm/wallet_test.go Normal file
View file

@ -0,0 +1,246 @@
package gpm
import (
"fmt"
"io/ioutil"
"os"
"testing"
)
func generateWalletWithEntries() Wallet {
var wallet Wallet
for i := 0; i < 10; i++ {
entry := Entry{ID: fmt.Sprintf("%d", i), Name: fmt.Sprintf("Entry %d", i), Group: "Good Group"}
wallet.AddEntry(entry)
}
return wallet
}
func TestAddBadEntry(t *testing.T) {
var entry Entry
var wallet Wallet
err := wallet.AddEntry(entry)
if err == nil {
t.Error("a bad entry must return an error")
}
}
func TestAddEntries(t *testing.T) {
var wallet Wallet
for i := 0; i < 10; i++ {
entry := Entry{ID: fmt.Sprintf("%d", i), Name: fmt.Sprintf("Entry %d", i)}
err := wallet.AddEntry(entry)
if err != nil {
t.Errorf("a good entry mustn't return an error: %s", err)
}
}
if len(wallet.Entries) != 10 {
t.Errorf("must have 10 entries: %d", len(wallet.Entries))
}
}
func TestSearchEntryWithBadID(t *testing.T) {
wallet := generateWalletWithEntries()
entry := wallet.SearchEntryByID("BAD-ID")
if entry.ID != "" {
t.Errorf("if the entry doesn't exist must return an empty Entry: %s", entry.ID)
}
}
func TestSearchEntryWithGoodID(t *testing.T) {
wallet := generateWalletWithEntries()
entry := wallet.SearchEntryByID("5")
if entry.ID != "5" {
t.Errorf("the ID entry must be 5: %s", entry.ID)
}
}
func TestSearchEntriesByGroup(t *testing.T) {
wallet := generateWalletWithEntries()
entries := len(wallet.SearchEntry("", "BAD-GROUP", false))
if entries != 0 {
t.Errorf("a search with bad group must return 0 entry: %d", entries)
}
entries = len(wallet.SearchEntry("", "good group", false))
if entries != 10 {
t.Errorf("a search with good group must return 10 entries: %d", entries)
}
}
func TestSearchEntriesByPattern(t *testing.T) {
wallet := generateWalletWithEntries()
entries := len(wallet.SearchEntry("BAD-PATTERN", "", false))
if entries != 0 {
t.Errorf("a search with bad pattern must return 0 entry: %d", entries)
}
entries = len(wallet.SearchEntry("entry", "", false))
if entries != 10 {
t.Errorf("a search with good pattern must return 10 entries: %d", entries)
}
entries = len(wallet.SearchEntry("^entry 5$", "", false))
if entries != 1 {
t.Errorf("a search with specific pattern must return 1 entry: %d", entries)
}
}
func TestSearchEntriesByPatternAndGroup(t *testing.T) {
wallet := generateWalletWithEntries()
entries := len(wallet.SearchEntry("entry", "good group", false))
if entries != 10 {
t.Errorf("a search with good pattern and godd group must return 10 entries: %d", entries)
}
}
func TestDeleteNotExistingEntry(t *testing.T) {
wallet := generateWalletWithEntries()
err := wallet.DeleteEntry("BAD-ID")
if err == nil {
t.Error("if the entry doesn't exist must return an error")
}
if len(wallet.Entries) != 10 {
t.Errorf("must have 10 entries: %d", len(wallet.Entries))
}
}
func TestDeleteEntry(t *testing.T) {
wallet := generateWalletWithEntries()
err := wallet.DeleteEntry("5")
if err != nil {
t.Errorf("a good entry mustn't return an error: %s", err)
}
if len(wallet.Entries) != 9 {
t.Errorf("must have 9 entries: %d", len(wallet.Entries))
}
if wallet.SearchEntryByID("5").ID != "" {
t.Error("must return an empty entry for the ID 5")
}
}
func TestUpdateNotExistingEntry(t *testing.T) {
wallet := generateWalletWithEntries()
err := wallet.UpdateEntry(Entry{ID: "BAD-ID"})
if err == nil {
t.Error("if the entry doesn't exist must return an error")
}
}
func TestUpdateEntry(t *testing.T) {
wallet := generateWalletWithEntries()
err := wallet.UpdateEntry(Entry{ID: "5"})
if err == nil {
t.Error("if the entry is bad must return an error")
}
err = wallet.UpdateEntry(Entry{ID: "5", Name: "Name 5"})
if err != nil {
t.Errorf("a good entry mustn't return an error: %s", err)
}
entry := wallet.SearchEntryByID("5")
if entry.Name != "Name 5" {
t.Errorf("the entry name for the ID 5 must be 'Name 5': %s", entry.Name)
}
}
func TestExportAndImport(t *testing.T) {
wallet := generateWalletWithEntries()
export, err := wallet.Export()
if err != nil {
t.Errorf("an export mustn't return an error: %s", err)
}
wallet = Wallet{}
err = wallet.Import(export)
if err != nil {
t.Errorf("a good import mustn't return an error: %s", err)
}
entries := len(wallet.Entries)
if entries != 10 {
t.Errorf("must have 10 entries: %d", entries)
}
}
func TestSaveWallet(t *testing.T) {
tmpFile, _ := ioutil.TempFile(os.TempDir(), "gpm_test-")
defer os.Remove(tmpFile.Name())
wallet := generateWalletWithEntries()
wallet.Path = tmpFile.Name()
wallet.Passphrase = "secret"
err := wallet.Save()
if err != nil {
t.Errorf("save wallet mustn't return an error: %s", err)
}
}
func TestLoadWalletWithGoodPassword(t *testing.T) {
var loadWallet Wallet
tmpFile, _ := ioutil.TempFile(os.TempDir(), "gpm_test-")
defer os.Remove(tmpFile.Name())
wallet := generateWalletWithEntries()
wallet.Path = tmpFile.Name()
wallet.Passphrase = "secret"
wallet.Save()
loadWallet.Path = wallet.Path
loadWallet.Passphrase = wallet.Passphrase
err := loadWallet.Load()
if err != nil {
t.Errorf("load wallet mustn't return an error: %s", err)
}
entries := len(loadWallet.Entries)
if entries != 10 {
t.Errorf("must have 10 entries: %d", entries)
}
}
func TestLoadWalletWithBadPassword(t *testing.T) {
var loadWallet Wallet
tmpFile, _ := ioutil.TempFile(os.TempDir(), "gpm_test-")
defer os.Remove(tmpFile.Name())
wallet := generateWalletWithEntries()
wallet.Path = tmpFile.Name()
wallet.Passphrase = "secret"
wallet.Save()
loadWallet.Path = wallet.Path
loadWallet.Passphrase = "bad secret"
err := loadWallet.Load()
if err == nil {
t.Error("load wallet with bad password must return an error")
}
entries := len(loadWallet.Entries)
if entries != 0 {
t.Errorf("must have 0 entries: %d", entries)
}
}
func TestGetGroup(t *testing.T) {
wallet := generateWalletWithEntries()
groups := wallet.Groups()
if len(groups) != 1 {
t.Errorf("there must have 1 group: %d", len(groups))
}
if groups[0] != "Good Group" {
t.Errorf("the group name isn't 'Good Group': %s", groups[0])
}
}

View file

@ -15,7 +15,7 @@
package main
import (
"git.yaegashi.fr/nishiki/gpm/gpm"
"code.waks.be/nishiki/gpm/internal/gpm"
)
func main() {