Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Migration to native Go library for lastpass. #56

Open
wants to merge 24 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ HOSTNAME=registry.terraform.io
NAMESPACE=nrkno
NAME=lastpass
BINARY=terraform-provider-${NAME}
VERSION=0.6.0
VERSION=1.0.0
OS_ARCH=darwin_amd64

default: install
Expand Down
25 changes: 22 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,25 +1,44 @@
# terraform-provider-lastpass
[![release](https://img.shields.io/github/release/nrkno/terraform-provider-lastpass.svg?style=flat-square)](https://github.com/nrkno/terraform-provider-lastpass/releases/latest) [![Build Status](https://travis-ci.com/nrkno/terraform-provider-lastpass.svg?branch=master)](https://travis-ci.com/nrkno/terraform-provider-lastpass) [![Go Report Card](https://goreportcard.com/badge/github.com/nrkno/terraform-provider-lastpass)](https://goreportcard.com/report/github.com/nrkno/terraform-provider-lastpass) [![GoDoc](https://godoc.org/github.com/github.com/nrkno/terraform-provider-lastpass/lastpass?status.svg)](https://godoc.org/github.com/nrkno/terraform-provider-lastpass/lastpass)

<img src="https://cdn.rawgit.com/hashicorp/terraform-website/master/content/source/assets/images/logo-hashicorp.svg" width="400px">
<img src="https://www.vectorlogo.zone/logos/terraformio/terraformio-ar21.svg" width="400px">

The Lastpass provider is used to read, manage, or destroy secrets inside Lastpass. Goodbye secret .tfvars files 👋

⚠️ **Warning**: This provider uses an unofficial LastPass API. With that comes the unfortunate risk of Lastpass releasing breaking changes without previous notice.

```hcl
terraform {
required_providers {
random = {
source = "hashicorp/random"
version = "3.1.0"
}
lastpass = {
source = "nrkno/lastpass"
version = ">= 1.0.0"
}
}
}

provider lastpass {
baseurl = "https://lastpass.eu"
trust = true
enable_2fa = true
}

resource "random_password" "pw" {
length = 32
special = false
}

resource "lastpass_secret" "mysecret" {
name = "My site"
username = "foobar"
password = file("${path.module}/secret")
password = random_password.pw.result
share = "Shared-TeamX"
url = "https://example.com"
note = <<EOF
notes = <<EOF
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam sed elit nec orci
cursus rhoncus. Morbi lacus turpis, volutpat in lobortis vel, mattis nec magna.
Cras gravida libero vitae nisl iaculis ultrices. Fusce odio ligula, pharetra ac
Expand Down
173 changes: 109 additions & 64 deletions api/client.go
Original file line number Diff line number Diff line change
@@ -1,91 +1,136 @@
package api

import (
"bytes"
"errors"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"

"github.com/ansd/lastpass-go"
)

// Secret describes a Lastpass object.
type Secret struct {
Fullname string `json:"fullname"`
Group string `json:"group"`
ID string `json:"id"`
LastModifiedGmt string `json:"last_modified_gmt"`
LastTouch string `json:"last_touch"`
Name string `json:"name"`
Note string `json:"note"`
Password string `json:"password"`
Share string `json:"share"`
URL string `json:"url"`
Username string `json:"username"`
CustomFields map[string]string `json:"custom_fields"`
ID string
Name string
Username string
Password string
URL string
Share string
Group string
Notes string
LastModifiedGmt string
LastTouch string
CustomFields map[string]string
}

// Client is our Lastpass (lpass) wrapper client.
// Client is our Lastpass (lastpass-go) wrapper client.
type Client struct {
Username string
Password string
Client *lastpass.Client
Accounts []*lastpass.Account
Username string
Password string
Trust bool
TwoFA bool
OnetimePW string
ConfigDIR string
BaseURL string
}

func (c *Client) Login() error {
var client *lastpass.Client
// authenticate with LastPass servers
basedir, err := os.UserConfigDir()
if err != nil {
return err
}
fullpath := filepath.Join(basedir, c.ConfigDIR)
if !c.TwoFA {
client, err = lastpass.NewClient(
context.Background(),
c.Username,
c.Password,
lastpass.WithBaseURL(c.BaseURL),
lastpass.WithConfigDir(fullpath))
}
if c.Trust {
client, err = lastpass.NewClient(
context.Background(),
c.Username,
c.Password,
lastpass.WithOneTimePassword(c.OnetimePW),
lastpass.WithBaseURL(c.BaseURL),
lastpass.WithTrust(),
lastpass.WithConfigDir(fullpath))
} else {
client, err = lastpass.NewClient(
context.Background(),
c.Username,
c.Password,
lastpass.WithOneTimePassword(c.OnetimePW),
lastpass.WithBaseURL(c.BaseURL),
lastpass.WithConfigDir(fullpath))
}
if err != nil {
return err
}
c.Client = client
return nil
}

func (c *Client) Sync() error {
// read all Accounts()
accounts, err := c.Client.Accounts(context.Background())
if err != nil {
return err
}
c.Accounts = accounts
return nil
}

func (s *Secret) genCustomFields() {
notes := make(map[string]string)
if strings.HasPrefix(s.Note, "NoteType:") {
splitted := strings.Split(s.Note, "\n")
for _, split := range splitted {
re := regexp.MustCompile(`:`)
s := re.Split(split, 2)
if s[0] == "Notes" {
break
}
if len(s) == 2 {
notes[s[0]] = s[1]
}
if strings.HasPrefix(s.Notes, "NoteType:") {
// fix notes so the regexp works
s.Notes = "\n" + s.Notes

// change '\n<words>:' to something more precise regexp can parse
tokenizer := regexp.MustCompile(`\n([[:alnum:]][ -_[:alnum:]]+:)`)
s.Notes = tokenizer.ReplaceAllString(s.Notes, "\a$1\a")

// break up notes using '\n<word>:<multi-line-string without control character bell>'
// - which implies that custom-fields values cannot include the bell character
// - allows for an inexpensive parser using regexp
splitter := regexp.MustCompile(`\a([ -_[:alnum:]]+):\a([^\a]*)`)
splitted := splitter.FindAllStringSubmatchIndex(s.Notes, -1)
fmt.Println(splitted)
for _, ss := range splitted {
fmt.Println("*>> ", string(s.Notes[ss[0]:ss[1]]))
fmt.Println("[0] ", string(s.Notes[ss[2]:ss[3]]))
fmt.Println("[1] ", string(s.Notes[ss[4]:ss[5]]))
}
// Fix for Notes with multiline. Always last in end of the string.
n := strings.Split(s.Note, "\nNotes:")
if len(n) == 2 {
notes["Notes"] = n[1]

for _, ss := range splitted {
key := s.Notes[ss[2]:ss[3]]
value := s.Notes[ss[4]:ss[5]]
if key == "Notes" && strings.Contains(value, "\n") {
notes[key] = strings.TrimSuffix(value, "\n")
} else {
notes[key] = value
}
}
}
s.CustomFields = notes
}

func (s *Secret) getTemplate() string {
template := fmt.Sprintf(`Name: %s
URL: %s
Username: %s
Password: %s
Notes: # Add notes below this line.
%s
`, s.Name, s.URL, s.Username, s.Password, s.Note)
return template
}

func (c *Client) login() error {
cmd := exec.Command("lpass", "status", "-q")
err := cmd.Run()
func epochToTime(s string) (string, error) {
sec, err := strconv.ParseInt(s, 10, 64)
if err != nil {
if c.Username == "" {
err := errors.New("Not logged in, please run 'lpass login' manually and try again")
return err
}
cmd := exec.Command("lpass", "login", c.Username)
var inbuf, errbuf bytes.Buffer
cmd.Env = os.Environ()
cmd.Env = append(cmd.Env, "LPASS_DISABLE_PINENTRY=1")
inbuf.Write([]byte(c.Password))
cmd.Stdin = &inbuf
cmd.Stderr = &errbuf
err := cmd.Run()
if err != nil {
var err = errors.New(errbuf.String())
return err
}
return "", err
}
return nil
return time.Unix(sec, 0).String(), nil
}
79 changes: 19 additions & 60 deletions api/create.go
Original file line number Diff line number Diff line change
@@ -1,71 +1,30 @@
package api

import (
"bytes"
"encoding/json"
"errors"
"os/exec"
"strings"
"time"
"context"

"github.com/ansd/lastpass-go"
)

// Create is used to create a new resource and generate ID.
func (c *Client) Create(s Secret) (Secret, error) {
err := c.login()
if err != nil {
return s, err
func (c *Client) Create(s *Secret) error {
a := &lastpass.Account{
Name: s.Name,
Username: s.Username,
Password: s.Password,
URL: s.URL,
Group: s.Group,
Share: s.Share,
Notes: s.Notes,
}
template := s.getTemplate()
cmd := exec.Command("lpass", "add", s.Name, "--non-interactive", "--sync=now")
var inbuf, errbuf bytes.Buffer
inbuf.Write([]byte(template))
cmd.Stdin = &inbuf
cmd.Stderr = &errbuf
err = cmd.Run()
err := c.Client.Add(context.Background(), a)
if err != nil {
var err = errors.New(errbuf.String())
return s, err
return err
}
var outbuf bytes.Buffer
var secrets []Secret
// because of the ridiculous way lpass sync works we will need to retry until we get our ID.
// see open issue at https://github.com/lastpass/lastpass-cli/issues/450
for i := 0; i < 10; i++ {
time.Sleep(time.Second * 2)
errbuf.Reset()
outbuf.Reset()
cmd = exec.Command("lpass", "sync")
cmd.Stderr = &errbuf
err = cmd.Run()
if err != nil {
var err = errors.New(errbuf.String())
return s, err
}
cmd = exec.Command("lpass", "show", "--sync=now", s.Name, "--json", "-x")
cmd.Stdout = &outbuf
cmd.Stderr = &errbuf
err = cmd.Run()
if err != nil {
if !strings.Contains(errbuf.String(), "Could not find specified account") {
var err = errors.New(errbuf.String())
return s, err
}
continue
}
err = json.Unmarshal(outbuf.Bytes(), &secrets)
if err != nil {
return s, err
}
if len(secrets) > 1 {
err := errors.New("more than one secret with same name, unable to determine ID")
return s, err
}
if secrets[0].ID == "0" {
// sync is still not done with upstream.
continue
}
return secrets[0], nil
s.ID = a.ID
err = c.Sync()
if err != nil {
return err
}
err = errors.New("timeout, unable to create new secret")
return s, err
return nil
}
31 changes: 16 additions & 15 deletions api/delete.go
Original file line number Diff line number Diff line change
@@ -1,28 +1,29 @@
package api

import (
"bytes"
"errors"
"os/exec"
"strings"
"context"

"github.com/ansd/lastpass-go"
)

// Delete secret in upstream db
func (c *Client) Delete(id string) error {
err := c.login()
func (c *Client) Delete(s *Secret) error {
account := &lastpass.Account{
ID: s.ID,
Name: s.Name,
Username: s.Username,
Password: s.Password,
URL: s.URL,
Group: s.Group,
Share: s.Share,
Notes: s.Notes,
}
err := c.Client.Delete(context.Background(), account)
if err != nil {
return err
}
var errbuf bytes.Buffer
cmd := exec.Command("lpass", "rm", id, "--sync=now")
cmd.Stderr = &errbuf
err = cmd.Run()
err = c.Sync()
if err != nil {
// Make sure the secret is not removed manually.
if strings.Contains(errbuf.String(), "Could not find specified account") {
return nil
}
var err = errors.New(errbuf.String())
return err
}
return nil
Expand Down
Loading