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

Support unlocking MacOS keyring via TouchID #115

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,13 @@ type Config struct {

// WinCredPrefix is a string prefix to prepend to the key name
WinCredPrefix string

// UseBiometrics is whether to use biometrics (where available) to unlock the keychain
UseBiometrics bool

// TouchIDAccount is the name of the account that we store the unlock password in keychain
TouchIDAccount string

// TouchIDService is the name of the service that we store the unlock password in keychain
TouchIDService string
}
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ require (
github.com/dvsekhvalnov/jose2go v1.5.0
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c
github.com/lox/go-touchid v0.0.0-20170712105233-619cc8e578d0
github.com/mtibben/percent v0.2.1
github.com/stretchr/testify v1.7.0
golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90
golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211
)
Expand Down
10 changes: 10 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NM
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/lox/go-touchid v0.0.0-20170712105233-619cc8e578d0 h1:m81erW+1MD5vl3lKQ/+TYPHJ6Y9/C1COqxXPE51FkDk=
github.com/lox/go-touchid v0.0.0-20170712105233-619cc8e578d0/go.mod h1:EHbIQzfC3kdWFI81pLOFjssnolF+ALfmVf8PUdWBxo4=
github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs=
github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
Expand All @@ -26,12 +28,20 @@ github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoH
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90 h1:Y/gsMcFOcR+6S6f3YeMKl5g+dZMEWqcz5Czj/GWYbkM=
golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a h1:ppl5mZgokTT8uPkmYOyEUmPTr3ypaKkg5eFOGrAmxxE=
golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/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-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.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U=
gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
Expand Down
134 changes: 134 additions & 0 deletions keychain.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,16 @@ package keyring
import (
"errors"
"fmt"
"os"

gokeychain "github.com/99designs/go-keychain"
"golang.org/x/crypto/ssh/terminal"

"github.com/lox/go-touchid"
)

const (
touchIDLabel = "Passphrase for %s"
)

type keychain struct {
Expand All @@ -19,6 +27,11 @@ type keychain struct {
isSynchronizable bool
isAccessibleWhenUnlocked bool
isTrusted bool

isTouchIDAuthenticated bool
useTouchID bool
touchIDAccount string
touchIDService string
}

func init() {
Expand All @@ -31,6 +44,21 @@ func init() {
// KeychainAccessibleWhenUnlocked is a shorthand for setting the accessibility value.
// See: https://developer.apple.com/documentation/security/ksecattraccessiblewhenunlocked
isAccessibleWhenUnlocked: cfg.KeychainAccessibleWhenUnlocked,

isTouchIDAuthenticated: false,
}
if cfg.UseBiometrics {
switch {
case cfg.TouchIDAccount == "":
return kc, fmt.Errorf("TouchIDAccount must be non-empty if UseBiometrics is true")

case cfg.TouchIDService == "":
return kc, fmt.Errorf("TouchIDService must be non-empty if UseBiometrics is true")
}

kc.useTouchID = true
kc.touchIDAccount = cfg.TouchIDAccount
kc.touchIDService = cfg.TouchIDService
}
if cfg.KeychainName != "" {
kc.path = cfg.KeychainName + ".keychain"
Expand Down Expand Up @@ -271,6 +299,9 @@ func (k *keychain) createOrOpen() (gokeychain.Keychain, error) {
debugf("Checking keychain status")
err := kc.Status()
if err == nil {
if k.useTouchID {
return k.openWithTouchID()
}
debugf("Keychain status returned nil, keychain exists")
return kc, nil
}
Expand All @@ -294,3 +325,106 @@ func (k *keychain) createOrOpen() (gokeychain.Keychain, error) {
debugf("Creating keychain %s with provided password", k.path)
return gokeychain.NewKeychain(k.path, passphrase)
}

func (k *keychain) openWithTouchID() (gokeychain.Keychain, error) {
if k.isTouchIDAuthenticated {
// already unlocked, return keychain
return gokeychain.NewWithPath(k.path), nil
}

debugf("checking with touchid")
ok, err := touchid.Authenticate("unlock " + k.path)
if err != nil {
return gokeychain.Keychain{}, fmt.Errorf("failed to authenticate with biometrics: %v", err)
}
if !ok {
return gokeychain.Keychain{}, fmt.Errorf("failed to authenticate with biometrics")
}

k.isTouchIDAuthenticated = true

debugf("looking up %s password in login.keychain", k.path)
query := gokeychain.NewItem()
query.SetSecClass(gokeychain.SecClassGenericPassword)
query.SetService(k.touchIDService)
query.SetAccount(k.touchIDAccount)
query.SetLabel(fmt.Sprintf(touchIDLabel, k.path))
query.SetMatchLimit(gokeychain.MatchLimitOne)
query.SetReturnData(true)

results, err := gokeychain.QueryItem(query)
if err != nil {
return gokeychain.Keychain{}, fmt.Errorf("failed to query keychain: %v", err)
}

var passphrase string
if len(results) != 1 {
// touch ID was never set up, let's do it now
var err error
passphrase, err = k.setupTouchID()
if err != nil {
return gokeychain.Keychain{}, fmt.Errorf("failed to setup touchid: %v", err)
}
} else {
debugf("found password in login.keychain, unlocking %s with stored password", k.path)
passphrase = string(results[0].Data)

// try unlocking with the passphrase we found
if err := gokeychain.UnlockAtPath(k.path, passphrase); err != nil {
return gokeychain.Keychain{}, fmt.Errorf("failed to unlock keychain: %v", err)
}
}
// either way we've unlocked the keychain so we should be able to return it

return gokeychain.NewWithPath(k.path), nil
}

func (k *keychain) setupTouchID() (string, error) {
fmt.Printf("\nTo use Touch ID for authentication, the aws-vault keychain password needs to be stored in your login keychain.\n" +
"You will be prompted for the password you use to unlock aws-vault.\n\n")

var passphrase string
if k.passwordFunc == nil {
debugf("Creating keychain %s with prompt", k.path)
fmt.Printf("Password for %q: ", k.path)
passphraseBytes, err := terminal.ReadPassword(int(os.Stdin.Fd()))
if err != nil {
return "", fmt.Errorf("failed to read password: %v", err)
}

passphrase = string(passphraseBytes)
} else {
var err error
passphrase, err = k.passwordFunc(fmt.Sprintf("Enter passphrase for %q", k.path))
if err != nil {
return "", fmt.Errorf("failed to get password: %v", err)
}
}

fmt.Println()
debugf("locking keychain %s", k.path)
if err := gokeychain.LockAtPath(k.path); err != nil {
return "", fmt.Errorf("failed to lock keychain: %v", err)
}

debugf("unlocking keychain %s", k.path)
if err := gokeychain.UnlockAtPath(k.path, passphrase); err != nil {
return "", fmt.Errorf("failed to unlock keychain: %v", err)
}

item := gokeychain.NewItem()
item.SetSecClass(gokeychain.SecClassGenericPassword)
item.SetService(k.touchIDService)
item.SetAccount(k.touchIDAccount)
item.SetLabel(fmt.Sprintf(touchIDLabel, k.path))
item.SetData([]byte(passphrase))
item.SetSynchronizable(gokeychain.SynchronizableNo)
item.SetAccessible(gokeychain.AccessibleWhenUnlocked)

debugf("Adding service=%q, account=%q to osx keychain %s", k.touchIDService, k.touchIDAccount, k.path)
if err := gokeychain.AddItem(item); err != nil {
return "", fmt.Errorf("failed to add item to keychain: %v", err)
}

return passphrase, nil
}