diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b261c7..3187120 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,20 @@ # Changelog - Paw +## 0.17.0 - 29 March 2022 + +- all: add Ed25519 and RSA SSH keys support +- deps add: + - github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a +- deps upgrade: + - fyne.io/fyne v2.1.4 + - golang.org/x/crypto v0.0.0-20220321153916-2c7772ba3064 + - golang.org/x/image v0.0.0-20220321031419-a8550c1d254a + - golang.org/x/sync v0.0.0-20210220032951-036812b2e83c + - golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 + ## 0.16.1 - 08 March 2022 -gui: fix item creation should show default content on cancel +- gui: fix item creation should show default content on cancel ## 0.16.0 - 28 February 2022 diff --git a/README.md b/README.md index 309914a..b720296 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,7 @@ Currently the following items are available: - login - note - password +- ssh_key ## Threat model diff --git a/go.mod b/go.mod index 802c349..623651f 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,12 @@ go 1.16 require ( filippo.io/age v1.0.0 - fyne.io/fyne/v2 v2.1.3 - github.com/stretchr/testify v1.7.0 + fyne.io/fyne/v2 v2.1.4 + github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a + github.com/stretchr/testify v1.7.1 golang.design/x/clipboard v0.6.0 - golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 - golang.org/x/image v0.0.0-20211028202545-6944b10bf410 + golang.org/x/crypto v0.0.0-20220321153916-2c7772ba3064 + golang.org/x/image v0.0.0-20220321031419-a8550c1d254a golang.org/x/sync v0.0.0-20210220032951-036812b2e83c - golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b + golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 ) diff --git a/go.sum b/go.sum index dbdde74..149a38a 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,8 @@ filippo.io/age v1.0.0 h1:V6q14n0mqYU3qKFkZ6oOaF9oXneOviS3ubXsSVBRSzc= filippo.io/age v1.0.0/go.mod h1:PaX+Si/Sd5G8LgfCwldsSba3H1DDQZhIhFGkhbHaBq8= filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns= -fyne.io/fyne/v2 v2.1.3 h1:I5qSeENAcq67hmO5Z2hI7sEJm9bdLMDJx59Fv8qJkX0= -fyne.io/fyne/v2 v2.1.3/go.mod h1:p+E/Dh+wPW8JwR2DVcsZ9iXgR9ZKde80+Y+40Is54AQ= +fyne.io/fyne/v2 v2.1.4 h1:bt1+28++kAzRzPB0GM2EuSV4cnl8rXNX4cjfd8G06Rc= +fyne.io/fyne/v2 v2.1.4/go.mod h1:p+E/Dh+wPW8JwR2DVcsZ9iXgR9ZKde80+Y+40Is54AQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= @@ -31,6 +31,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/lucor/goinfo v0.0.0-20210802170112-c078a2b0f08b/go.mod h1:PRq09yoB+Q2OJReAmwzKivcYyremnibWGbK7WfftHzc= +github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a h1:eU8j/ClY2Ty3qdHnn0TyW3ivFoPC/0F1gQZz8yTxbbE= +github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a/go.mod h1:v8eSC2SMp9/7FTKUncp7fH9IwPfw+ysMObcEz5FWheQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= @@ -49,8 +51,8 @@ github.com/srwiley/rasterx v0.0.0-20200120212402-85cb7272f5e9/go.mod h1:mvWM0+15 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.3.8 h1:Nw158Q8QN+CPgTmVRByhVwapp8Mm1e2blinhmx4wx5E= @@ -60,16 +62,17 @@ golang.design/x/clipboard v0.6.0/go.mod h1:ep0pB+/4DGJK3ayLxweWJFHhHGGv3npJJHMXA golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 h1:HWj/xjIHfjYU5nVXpTM0s39J9CbLn7Cc5a7IC5rwsMQ= golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220321153916-2c7772ba3064 h1:S25/rfnfsMVgORT4/J61MJ7rdyseOZOyvLIrZEZ7s6s= +golang.org/x/crypto v0.0.0-20220321153916-2c7772ba3064/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 h1:estk1glOnSVeJ9tdEZZc5mAMDZk5lNJNyJ6DvrBkTEU= golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20200430140353-33d19683fad8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= -golang.org/x/image v0.0.0-20211028202545-6944b10bf410 h1:hTftEOvwiOq2+O8k2D5/Q7COC7k5Qcrgc2TFURJYnvQ= -golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= +golang.org/x/image v0.0.0-20220321031419-a8550c1d254a h1:LnH9RNcpPv5Kzi15lXg42lYMPUf0x8CuPv1YnvBWZAg= +golang.org/x/image v0.0.0-20220321031419-a8550c1d254a/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= golang.org/x/mobile v0.0.0-20210716004757-34ab1303b554 h1:3In5TnfvnuXTF/uflgpYxSCEGP2NdYT37KsPh3VjZYU= golang.org/x/mobile v0.0.0-20210716004757-34ab1303b554/go.mod h1:jFTmtFYCV0MFtXBU+J5V/+5AUeVS0ON/0WkE/KSrl6E= @@ -79,8 +82,9 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4 h1:4nGaVu0QrbjT/AK2PRLuQfQuh6DJve+pELhqTdAj3x0= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 h1:CIJ76btIcR3eFI5EgSo6k1qKw9KJexJuRLI9G7Hp5wE= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -90,14 +94,16 @@ golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210903071746-97244b99971b h1:3Dq0eVHn0uaQJmPO+/aYPI/fRMqdrVDbu7MQcku54gg= golang.org/x/sys v0.0.0-20210903071746-97244b99971b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b h1:9zKuko04nR4gjZ4+DNjHqRlAJqbJETHwiNKDqTfOjfE= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= diff --git a/internal/cli/cmd_add.go b/internal/cli/cmd_add.go index cb2d4b2..f199857 100644 --- a/internal/cli/cmd_add.go +++ b/internal/cli/cmd_add.go @@ -5,6 +5,7 @@ import ( "os" "lucor.dev/paw/internal/paw" + "lucor.dev/paw/internal/sshkey" ) // Add adds an item to the vault @@ -83,6 +84,8 @@ func (cmd *AddCmd) Run(s paw.Storage) error { cmd.addNoteItem(item) case paw.PasswordItemType: cmd.addPasswordItem(vault.Key(), item) + case paw.SSHKeyItemType: + cmd.addSSHKeyItem(item) default: return fmt.Errorf("unsupported item type: %q", cmd.itemType) } @@ -185,3 +188,25 @@ func (cmd *AddCmd) addPasswordItem(key *paw.Key, item paw.Item) error { item = v return nil } + +func (cmd *AddCmd) addSSHKeyItem(item paw.Item) error { + v := item.(*paw.SSHKey) + + k, err := sshkey.GenerateKey() + if err != nil { + return err + } + + v.PrivateKey = string(k.PrivateKey()) + v.PublicKey = string(k.PublicKey()) + v.Fingerprint = k.Fingerprint() + + note, err := ask("Note") + if err != nil { + return err + } + + v.Note.Value = note + item = v + return nil +} diff --git a/internal/cli/cmd_edit.go b/internal/cli/cmd_edit.go index 1b4ad99..549b6cd 100644 --- a/internal/cli/cmd_edit.go +++ b/internal/cli/cmd_edit.go @@ -85,6 +85,8 @@ func (cmd *EditCmd) Run(s paw.Storage) error { cmd.editNoteItem(item) case paw.PasswordItemType: cmd.editPasswordItem(vault.Key(), item) + case paw.SSHKeyItemType: + cmd.editSSHKeyItem(item) default: return fmt.Errorf("unsupported item type: %q", cmd.itemType) } @@ -189,3 +191,16 @@ func (cmd *EditCmd) editPasswordItem(key *paw.Key, item paw.Item) error { item = v return nil } + +func (cmd *EditCmd) editSSHKeyItem(item paw.Item) error { + v := item.(*paw.SSHKey) + + note, err := askWithDefault("Note", v.Note.Value) + if err != nil { + return err + } + + v.Note.Value = note + item = v + return nil +} diff --git a/internal/cli/cmd_list.go b/internal/cli/cmd_list.go index d266121..9f6a9e6 100644 --- a/internal/cli/cmd_list.go +++ b/internal/cli/cmd_list.go @@ -113,6 +113,7 @@ func (cmd *ListCmd) items(s paw.Storage) ([]tree.Node, error) { loginNode := tree.Node{Value: paw.LoginItemType.String()} noteNode := tree.Node{Value: paw.NoteItemType.String()} passwordNode := tree.Node{Value: paw.PasswordItemType.String()} + sshkeyNode := tree.Node{Value: paw.SSHKeyItemType.String()} for _, v := range meta { switch v.Type { case paw.LoginItemType: @@ -121,6 +122,8 @@ func (cmd *ListCmd) items(s paw.Storage) ([]tree.Node, error) { noteNode.Child = append(noteNode.Child, tree.Node{Value: v.Name}) case paw.PasswordItemType: passwordNode.Child = append(passwordNode.Child, tree.Node{Value: v.Name}) + case paw.SSHKeyItemType: + sshkeyNode.Child = append(sshkeyNode.Child, tree.Node{Value: v.Name}) } } @@ -128,6 +131,7 @@ func (cmd *ListCmd) items(s paw.Storage) ([]tree.Node, error) { loginNode, noteNode, passwordNode, + sshkeyNode, }, nil } diff --git a/internal/cli/cmd_show.go b/internal/cli/cmd_show.go index 2aa6419..7518c03 100644 --- a/internal/cli/cmd_show.go +++ b/internal/cli/cmd_show.go @@ -94,6 +94,7 @@ func (cmd *ShowCmd) Run(s paw.Storage) error { } var pclip []byte + var pclipMsg string switch cmd.itemType { case paw.LoginItemType: v := item.(*paw.Login) @@ -103,6 +104,7 @@ func (cmd *ShowCmd) Run(s paw.Storage) error { fmt.Printf("Password: %s\n", v.Password.Value) } else { pclip = []byte(v.Password.Value) + pclipMsg = "[✓] password copied to clipboard" } if v.Note != nil { fmt.Printf("Note: %s\n", v.Note.Value) @@ -113,10 +115,24 @@ func (cmd *ShowCmd) Run(s paw.Storage) error { fmt.Printf("Password: %s\n", v.Value) } else { pclip = []byte(v.Value) + pclipMsg = "[✓] password copied to clipboard" } if v.Note != nil { fmt.Printf("Note: %s\n", v.Note.Value) } + case paw.SSHKeyItemType: + v := item.(*paw.SSHKey) + if !cmd.clipboard { + fmt.Printf("Private key: %s\n", v.PrivateKey) + } else { + pclip = []byte(v.PrivateKey) + pclipMsg = "[✓] private key copied to clipboard" + } + fmt.Printf("Public key: %s\n", v.PublicKey) + fmt.Printf("Fingerprint: %s\n", v.Fingerprint) + if v.Note != nil { + fmt.Printf("Note: %s\n", v.Note.Value) + } case paw.NoteItemType: v := item.(*paw.Note) fmt.Printf("Note: %s\n", v.Value) @@ -132,7 +148,7 @@ func (cmd *ShowCmd) Run(s paw.Storage) error { if err != nil { return nil } - fmt.Println("[✓] password copied to clipboard") + fmt.Println(pclipMsg) } return nil } diff --git a/internal/icon/download_outlined.go b/internal/icon/download_outlined.go new file mode 100644 index 0000000..a0f34d3 --- /dev/null +++ b/internal/icon/download_outlined.go @@ -0,0 +1,18 @@ +// auto-generated +// Code generated by 'fynematic'. DO NOT EDIT. + +package icon + +import "fyne.io/fyne/v2" + +var DownloadOutlinedIconThemed = NewThemedResource(DownloadOutlinedIconDarkRes, DownloadOutlinedIconLightRes) + +var DownloadOutlinedIconDarkRes = &fyne.StaticResource{ + StaticName: "download_outlined_dark.svg", + StaticContent: []byte(""), +} + +var DownloadOutlinedIconLightRes = &fyne.StaticResource{ + StaticName: "download_outlined_light.svg", + StaticContent: []byte(""), +} diff --git a/internal/icon/upload_outlined.go b/internal/icon/upload_outlined.go new file mode 100644 index 0000000..b8c77f2 --- /dev/null +++ b/internal/icon/upload_outlined.go @@ -0,0 +1,18 @@ +// auto-generated +// Code generated by 'fynematic'. DO NOT EDIT. + +package icon + +import "fyne.io/fyne/v2" + +var UploadOutlinedIconThemed = NewThemedResource(UploadOutlinedIconDarkRes, UploadOutlinedIconLightRes) + +var UploadOutlinedIconDarkRes = &fyne.StaticResource{ + StaticName: "upload_outlined_dark.svg", + StaticContent: []byte(""), +} + +var UploadOutlinedIconLightRes = &fyne.StaticResource{ + StaticName: "upload_outlined_light.svg", + StaticContent: []byte(""), +} diff --git a/internal/paw/item.go b/internal/paw/item.go index ae0ce41..f8f3f45 100644 --- a/internal/paw/item.go +++ b/internal/paw/item.go @@ -16,6 +16,8 @@ const ( PasswordItemType // LoginItemType is the Website Item type LoginItemType + // SSHKeyItemType is the SSH Key Item type + SSHKeyItemType ) func (it ItemType) String() string { @@ -28,6 +30,8 @@ func (it ItemType) String() string { return "password" case LoginItemType: return "login" + case SSHKeyItemType: + return "ssh_key" } return "invalid" } @@ -42,6 +46,8 @@ func ItemTypeFromString(v string) (ItemType, error) { itemType = NoteItemType case PasswordItemType.String(): itemType = PasswordItemType + case SSHKeyItemType.String(): + itemType = SSHKeyItemType default: err = fmt.Errorf("invalid item type %q", v) } @@ -67,6 +73,8 @@ func NewItem(name string, itemType ItemType) (Item, error) { item = NewNote() case PasswordItemType: item = NewPassword() + case SSHKeyItemType: + item = NewSSHKey() default: return nil, fmt.Errorf("invalid item type %q", itemType) } diff --git a/internal/paw/item_ssh_key.go b/internal/paw/item_ssh_key.go new file mode 100644 index 0000000..bd7ae5b --- /dev/null +++ b/internal/paw/item_ssh_key.go @@ -0,0 +1,29 @@ +package paw + +import ( + "time" +) + +// Declare conformity to Item interface +var _ Item = (*SSHKey)(nil) + +type SSHKey struct { + *Metadata `json:"metadata,omitempty"` + *Note `json:"note,omitempty"` + + Fingerprint string `json:"fingerprint,omitempty"` + PrivateKey string `json:"private_key,omitempty"` + PublicKey string `json:"public_key,omitempty"` +} + +func NewSSHKey() *SSHKey { + now := time.Now() + return &SSHKey{ + Metadata: &Metadata{ + Type: SSHKeyItemType, + Created: now, + Modified: now, + }, + Note: &Note{}, + } +} diff --git a/internal/paw/paw.go b/internal/paw/paw.go index 7a5575e..06f53bc 100644 --- a/internal/paw/paw.go +++ b/internal/paw/paw.go @@ -186,7 +186,7 @@ func (k *Key) Secret(seeder Seeder) (string, error) { // decode the age identity to be used as secret for HKDF function _, data, err := bech32.Decode(k.ageIdentity.String()) if err != nil { - return "", fmt.Errorf("could not decode the age identity %w", err) + panic(fmt.Sprintf("could not decode the age identity %s", err)) } // reader to derive a key diff --git a/internal/paw/storage_os.go b/internal/paw/storage_os.go index 8a54b10..9454319 100644 --- a/internal/paw/storage_os.go +++ b/internal/paw/storage_os.go @@ -172,6 +172,8 @@ func (s *OSStorage) LoadItem(vault *Vault, itemMetadata *Metadata) (Item, error) item = &Password{} case LoginItemType: item = &Login{} + case SSHKeyItemType: + item = &SSHKey{} } itemFile := itemPath(s, vault.Name, itemMetadata.ID()) diff --git a/internal/sshkey/sshkey.go b/internal/sshkey/sshkey.go new file mode 100644 index 0000000..58b32bb --- /dev/null +++ b/internal/sshkey/sshkey.go @@ -0,0 +1,79 @@ +package sshkey + +import ( + "crypto" + "crypto/ed25519" + cryptorand "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + + "github.com/mikesmitty/edkey" + "golang.org/x/crypto/ssh" +) + +// GenerateKey generates an ed25519 sshkey +func GenerateKey() (sshkey, error) { + pubKey, privKey, err := ed25519.GenerateKey(cryptorand.Reader) + if err != nil { + return sshkey{}, fmt.Errorf("could not generate ed25519 key: %w", err) + } + return sshkey{privateKey: &privKey, publicKey: pubKey}, nil +} + +// ParseKey parses a raw RSA or Ed22519 ssh key +func ParseKey(b []byte) (sshkey, error) { + k, err := ssh.ParseRawPrivateKey(b) + if err != nil { + return sshkey{}, err + } + switch v := k.(type) { + case *ed25519.PrivateKey: + return sshkey{privateKey: v, publicKey: v.Public()}, err + case *rsa.PrivateKey: + return sshkey{privateKey: v, publicKey: v.Public()}, err + default: + return sshkey{}, fmt.Errorf("unsupported type %T", v) + } +} + +type sshkey struct { + privateKey crypto.PrivateKey + publicKey crypto.PublicKey +} + +func (sk sshkey) PrivateKey() []byte { + var pemBlock *pem.Block + switch v := sk.privateKey.(type) { + case *ed25519.PrivateKey: + // TODO move to x/crypto/ssh once https://go-review.googlesource.com/c/crypto/+/218620/ is merged + // see golang/go#37132 + pemBlock = &pem.Block{ + Type: "OPENSSH PRIVATE KEY", + Bytes: edkey.MarshalED25519PrivateKey(*v), + } + case *rsa.PrivateKey: + pemBlock = &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(v), + } + } + return pem.EncodeToMemory(pemBlock) +} + +func (sk sshkey) sshPublicKey() ssh.PublicKey { + sshPublicKey, err := ssh.NewPublicKey(sk.publicKey) + if err != nil { + panic("could not generate ssh public key from the crypto public key") + } + return sshPublicKey +} + +func (sk sshkey) PublicKey() []byte { + return ssh.MarshalAuthorizedKey(sk.sshPublicKey()) +} + +func (sk sshkey) Fingerprint() string { + return ssh.FingerprintSHA256(sk.sshPublicKey()) +} diff --git a/internal/ui/dialog.go b/internal/ui/dialog.go deleted file mode 100644 index d36fd87..0000000 --- a/internal/ui/dialog.go +++ /dev/null @@ -1,19 +0,0 @@ -package ui - -import ( - "fmt" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/dialog" - "fyne.io/fyne/v2/widget" -) - -func ShowErrorDialog(title string, err error, w fyne.Window) { - content := &widget.Label{ - Text: fmt.Sprintf("Error: %s", err), - Wrapping: fyne.TextWrapBreak, - } - - d := dialog.NewCustom(title, "Ok", content, w) - d.Show() -} diff --git a/internal/ui/item_fyne.go b/internal/ui/item_fyne.go index 9f3fd5c..de13e58 100644 --- a/internal/ui/item_fyne.go +++ b/internal/ui/item_fyne.go @@ -10,9 +10,11 @@ import ( "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/data/binding" + "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" + "lucor.dev/paw/internal/icon" "lucor.dev/paw/internal/paw" ) @@ -42,7 +44,8 @@ func NewFyneItem(item paw.Item) FyneItem { fyneItem = &Login{Login: item.(*paw.Login)} case paw.PasswordItemType: fyneItem = &Password{Password: item.(*paw.Password)} - + case paw.SSHKeyItemType: + fyneItem = &SSHKey{SSHKey: item.(*paw.SSHKey)} } return fyneItem } @@ -63,8 +66,55 @@ func labelWithStyle(label string) *widget.Label { return widget.NewLabelWithStyle(label, fyne.TextAlignTrailing, fyne.TextStyle{Bold: true}) } +type rowActions struct { + copy bool + ellipsis int + export string +} + +func rowWithAction(label string, text string, actions rowActions, w fyne.Window) []fyne.CanvasObject { + labelText := text + if actions.ellipsis > 0 { + labelText = text[0:actions.ellipsis] + "..." + } + t := widget.NewLabel(labelText) + t.Wrapping = fyne.TextWrapBreak + + c := container.NewVBox() + if actions.copy { + b := widget.NewButtonWithIcon("Copy", theme.ContentCopyIcon(), func() { + w.Clipboard().SetContent(text) + fyne.CurrentApp().SendNotification(&fyne.Notification{ + Title: "paw", + Content: fmt.Sprintf("%s copied", label), + }) + }) + c.Add(b) + } + + if actions.export != "" { + b := widget.NewButtonWithIcon("Export", icon.DownloadOutlinedIconThemed, func() { + d := dialog.NewFileSave(func(uc fyne.URIWriteCloser, err error) { + if uc == nil { + // file open dialog has been cancelled + return + } + defer uc.Close() + uc.Write([]byte(text)) + }, w) + d.SetFileName(actions.export) + d.Show() + }) + c.Add(b) + } + + l := labelWithStyle(label) + return []fyne.CanvasObject{l, container.NewBorder(nil, nil, nil, c, t)} +} + func copiableRow(label string, text string, w fyne.Window) []fyne.CanvasObject { t := widget.NewLabel(text) + t.Wrapping = fyne.TextWrapBreak b := widget.NewButtonWithIcon("Copy", theme.ContentCopyIcon(), func() { w.Clipboard().SetContent(text) fyne.CurrentApp().SendNotification(&fyne.Notification{ @@ -74,7 +124,7 @@ func copiableRow(label string, text string, w fyne.Window) []fyne.CanvasObject { }) l := labelWithStyle(label) - return []fyne.CanvasObject{l, container.NewBorder(nil, nil, nil, b, t)} + return []fyne.CanvasObject{l, container.NewBorder(nil, nil, nil, container.NewVBox(b), t)} } func copiableLinkRow(label string, text string, w fyne.Window) []fyne.CanvasObject { diff --git a/internal/ui/item_metadata.go b/internal/ui/item_metadata.go index 2f9260e..063a96a 100644 --- a/internal/ui/item_metadata.go +++ b/internal/ui/item_metadata.go @@ -35,6 +35,8 @@ func (m *Metadata) Icon() fyne.Resource { return icon.PasswordOutlinedIconThemed case paw.LoginItemType: return icon.PublicOutlinedIconThemed + case paw.SSHKeyItemType: + return icon.KeyOutlinedIconThemed } return icon.PawIcon } diff --git a/internal/ui/item_sshkey.go b/internal/ui/item_sshkey.go new file mode 100644 index 0000000..d70636b --- /dev/null +++ b/internal/ui/item_sshkey.go @@ -0,0 +1,183 @@ +package ui + +import ( + "context" + "fmt" + "io" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/data/binding" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + + "lucor.dev/paw/internal/icon" + "lucor.dev/paw/internal/paw" + "lucor.dev/paw/internal/sshkey" +) + +// Declare conformity to Item interface +var _ paw.Item = (*Password)(nil) + +// Declare conformity to FyneItem interface +var _ FyneItem = (*Password)(nil) + +type SSHKey struct { + *paw.SSHKey +} + +func (sh *SSHKey) Item() paw.Item { + return sh.SSHKey +} + +func (sh *SSHKey) Icon() fyne.Resource { + if sh.Favicon != nil { + return sh.Favicon + } + return icon.KeyOutlinedIconThemed +} + +func (sh *SSHKey) Edit(ctx context.Context, key *paw.Key, w fyne.Window) (fyne.CanvasObject, paw.Item) { + sshKeyItem := &paw.SSHKey{} + *sshKeyItem = *sh.SSHKey + sshKeyItem.Metadata = &paw.Metadata{} + *sshKeyItem.Metadata = *sh.Metadata + sshKeyItem.Note = &paw.Note{} + *sshKeyItem.Note = *sh.Note + + titleEntryBind := binding.BindString(&sshKeyItem.Name) + titleEntry := widget.NewEntryWithData(titleEntryBind) + titleEntry.Validator = nil + titleEntry.PlaceHolder = "Untitled SSH Key" + + publicKeyEntryBind := binding.BindString(&sshKeyItem.PublicKey) + publicKeyEntry := widget.NewEntryWithData(publicKeyEntryBind) + publicKeyEntry.Validator = nil + publicKeyEntry.MultiLine = true + publicKeyEntry.Wrapping = fyne.TextWrapBreak + publicKeyEntry.Disable() + publicKeyCopyButton := widget.NewButtonWithIcon("Copy", theme.ContentCopyIcon(), func() { + w.Clipboard().SetContent(publicKeyEntry.Text) + fyne.CurrentApp().SendNotification(&fyne.Notification{ + Title: "paw", + Content: "Public Key copied to clipboard", + }) + }) + publicKeyExportButton := widget.NewButtonWithIcon("Export", icon.DownloadOutlinedIconThemed, func() { + d := dialog.NewFileSave(func(uc fyne.URIWriteCloser, err error) { + if uc == nil { + // file open dialog has been cancelled + return + } + defer uc.Close() + v, err := publicKeyEntryBind.Get() + if err != nil { + dialog.NewError(err, w).Show() + return + } + uc.Write([]byte(v)) + }, w) + filename, _ := titleEntryBind.Get() + d.SetFileName(fmt.Sprintf("%s.pub", filename)) + d.Show() + }) + + fingerprintEntryBind := binding.BindString(&sshKeyItem.Fingerprint) + fingerprintEntry := widget.NewLabelWithData(fingerprintEntryBind) + + privateKeyEntryBind := binding.BindString(&sshKeyItem.PrivateKey) + privateKeyEntry := widget.NewEntryWithData(privateKeyEntryBind) + privateKeyEntry.Validator = nil + privateKeyEntry.MultiLine = true + privateKeyEntry.Disable() + privateKeyEntry.SetPlaceHolder("Private Key") + privateKeyCopyButton := widget.NewButtonWithIcon("Copy", theme.ContentCopyIcon(), func() { + w.Clipboard().SetContent(privateKeyEntry.Text) + fyne.CurrentApp().SendNotification(&fyne.Notification{ + Title: "paw", + Content: "Private Key copied to clipboard", + }) + }) + privateKeyMakeButton := widget.NewButtonWithIcon("Generate", icon.KeyOutlinedIconThemed, func() { + sk, err := sshkey.GenerateKey() + if err != nil { + dialog.NewError(err, w).Show() + return + } + privateKeyEntryBind.Set(string(sk.PrivateKey())) + publicKeyEntryBind.Set(string(sk.PublicKey())) + fingerprintEntryBind.Set(string(sk.Fingerprint())) + }) + + privateKeyImportButton := widget.NewButtonWithIcon("Import", icon.UploadOutlinedIconThemed, func() { + d := dialog.NewFileOpen(func(uc fyne.URIReadCloser, e error) { + b, err := io.ReadAll(uc) + uc.Close() + if err != nil { + dialog.NewError(err, w).Show() + return + } + sk, err := sshkey.ParseKey(b) + if err != nil { + dialog.NewError(err, w).Show() + return + } + privateKeyEntryBind.Set(string(sk.PrivateKey())) + publicKeyEntryBind.Set(string(sk.PublicKey())) + fingerprintEntryBind.Set(string(sk.Fingerprint())) + }, w) + d.Show() + }) + + privateKeyExportButton := widget.NewButtonWithIcon("Export", icon.DownloadOutlinedIconThemed, func() { + d := dialog.NewFileSave(func(uc fyne.URIWriteCloser, err error) { + if uc == nil { + // file open dialog has been cancelled + return + } + defer uc.Close() + v, err := privateKeyEntryBind.Get() + if err != nil { + dialog.NewError(err, w).Show() + return + } + uc.Write([]byte(v)) + }, w) + filename, _ := titleEntryBind.Get() + d.SetFileName(filename) + d.Show() + }) + + noteEntry := widget.NewEntryWithData(binding.BindString(&sshKeyItem.Note.Value)) + noteEntry.MultiLine = true + noteEntry.Validator = nil + + form := container.New(layout.NewFormLayout()) + form.Add(widget.NewIcon(sh.Icon())) + form.Add(titleEntry) + + form.Add(labelWithStyle("Private Key")) + form.Add(container.NewBorder(nil, nil, nil, container.NewVBox(privateKeyCopyButton, privateKeyMakeButton, privateKeyImportButton, privateKeyExportButton), privateKeyEntry)) + + form.Add(labelWithStyle("Public Key")) + form.Add(container.NewBorder(nil, nil, nil, container.NewVBox(publicKeyCopyButton, publicKeyExportButton), publicKeyEntry)) + + form.Add(labelWithStyle("Fingerprint")) + form.Add(fingerprintEntry) + + form.Add(labelWithStyle("Note")) + form.Add(noteEntry) + + return form, sshKeyItem +} + +func (sh *SSHKey) Show(ctx context.Context, w fyne.Window) fyne.CanvasObject { + obj := titleRow(sh.Icon(), sh.Name) + obj = append(obj, rowWithAction("Private Key", sh.PrivateKey, rowActions{copy: true, ellipsis: 64, export: sh.Name}, w)...) + obj = append(obj, rowWithAction("Public Key", sh.PrivateKey, rowActions{copy: true, ellipsis: 64, export: sh.Name + ".pub"}, w)...) + obj = append(obj, copiableRow("Fingerprint", sh.Fingerprint, w)...) + obj = append(obj, copiableRow("Note", sh.Note.Value, w)...) + return container.New(layout.NewFormLayout(), obj...) +} diff --git a/internal/ui/vault_view.go b/internal/ui/vault_view.go index 5361b18..c3be6fc 100644 --- a/internal/ui/vault_view.go +++ b/internal/ui/vault_view.go @@ -66,7 +66,28 @@ func newVaultView(mw *mainView, vault *paw.Vault) *vaultView { vw.itemsWidget = newItemsWidget(vw.vault, vw.filterOptions) vw.itemsWidget.OnSelected = func(meta *paw.Metadata) { - item, _ := vw.mainView.storage.LoadItem(vw.vault, meta) + item, err := vw.mainView.storage.LoadItem(vw.vault, meta) + if err != nil { + msg := fmt.Sprintf("error loading %q.\nDo you want delete from the vault?", meta.Name) + fyne.LogError("error loading item from vault", err) + dialog.NewConfirm( + "Error", + msg, + func(delete bool) { + if delete { + item, _ = paw.NewItem(meta.Name, meta.Type) + vw.vault.DeleteItem(item) // remove item from vault + vw.mainView.storage.DeleteItem(vw.vault, item) // remove item from storage + vw.mainView.storage.StoreVault(vw.vault) // ensure vault is up-to-date + vw.itemsWidget.Reload(nil, vw.filterOptions) + vw.setContent(vw.defaultContent()) + vw.Reload() + } + }, + vw.mainView, + ).Show() + return + } vw.setContentItem(NewFyneItem(item), vw.itemView) } vw.typeSelectEntry = vw.makeTypeSelectEntry() @@ -247,11 +268,13 @@ func (vw *vaultView) makeItems() []paw.Item { Hash: paw.TOTPHash(TOTPHash()), Interval: TOTPInverval(), } + sshkey := paw.NewSSHKey() return []paw.Item{ note, password, website, + sshkey, } } @@ -486,7 +509,7 @@ func (vw *vaultView) auditPasswordView() fyne.CanvasObject { defer modal.Hide() err := g.Wait() if err != nil || errors.Is(ctx.Err(), context.Canceled) { - ShowErrorDialog("Error auditing items", err, vw.mainView) + dialog.ShowError(err, vw.mainView) return } @@ -583,7 +606,7 @@ func (vw *vaultView) importFromFile() { err := json.NewDecoder(uc).Decode(&data) if err != nil { modal.Hide() - ShowErrorDialog("Error importing items", err, vw.mainView) + dialog.ShowError(err, vw.mainView) return } @@ -618,7 +641,7 @@ func (vw *vaultView) importFromFile() { err = g.Wait() if err != nil || errors.Is(ctx.Err(), context.Canceled) { rollback(vw.vault, processed) - ShowErrorDialog("Error importing items", err, vw.mainView) + dialog.ShowError(err, vw.mainView) return } @@ -628,7 +651,7 @@ func (vw *vaultView) importFromFile() { err = vw.mainView.storage.StoreVault(vw.vault) if err != nil { rollback(vw.vault, processed) - ShowErrorDialog("Error importing items", err, vw.mainView) + dialog.ShowError(err, vw.mainView) return } vw.itemsWidget.Reload(nil, vw.filterOptions) @@ -713,13 +736,13 @@ func (vw *vaultView) exportToFile() { defer modal.Hide() err := g.Wait() if err != nil || errors.Is(ctx.Err(), context.Canceled) { - ShowErrorDialog("Error exporting items", err, vw.mainView) + dialog.ShowError(err, vw.mainView) return } err = json.NewEncoder(uc).Encode(data) if err != nil { - ShowErrorDialog("Error exporting items", err, vw.mainView) + dialog.ShowError(err, vw.mainView) } }() modal.Show()