diff --git a/config.go b/config.go index 590af7c..b3937f9 100644 --- a/config.go +++ b/config.go @@ -23,6 +23,15 @@ type Config struct { // KeychainPasswordFunc is an optional function used to prompt the user for a password KeychainPasswordFunc PromptFunc + // Access control options for the (data protection keychain) + KeychainAccessControl []string + + // Access constraint for the (data protection keychain) + KeychainAccessConstraint string + + // Number of seconds to allow reuse of biometrics without prompting the user + BioMetricsAllowableReuseDuration int // seconds + // FilePasswordFunc is a required function used to prompt the user for a password FilePasswordFunc PromptFunc diff --git a/data_protection_keychain.go b/data_protection_keychain.go new file mode 100644 index 0000000..73412a3 --- /dev/null +++ b/data_protection_keychain.go @@ -0,0 +1,308 @@ +//go:build darwin && cgo +// +build darwin,cgo + +package keyring + +import ( + "errors" + "fmt" + + gokeychain "github.com/keybase/go-keychain" +) + +type DataProtectionKeychain struct { + service string + + authenticationContext *gokeychain.AuthenticationContext + + isSynchronizable bool + accessControlFlags gokeychain.AccessControlFlags + accessConstraint gokeychain.Accessible +} + +func init() { + supportedBackends[DataProtectionKeychainBackend] = opener(func(cfg Config) (Keyring, error) { + if !gokeychain.CanUseDataProtectionKeychain() { + return nil, errors.New("SecAccessControl is not available on this platform") + } + + var authCtxOptions gokeychain.AuthenticationContextOptions + + if cfg.BioMetricsAllowableReuseDuration > 0 { + authCtxOptions.AllowableReuseDuration = cfg.BioMetricsAllowableReuseDuration + } else if cfg.BioMetricsAllowableReuseDuration < 0 { + return nil, errors.New("BioMetricsAllowableReuseDuration must be greater than 0") + } + + authCtx := gokeychain.CreateAuthenticationContext(authCtxOptions) + + accessConstraint, err := mapConstraint(cfg.KeychainAccessConstraint) + if err != nil { + return nil, err + } + + accessControlFlags, err := mapStringsToFlags(cfg.KeychainAccessControl) + if err != nil { + return nil, err + } + + kc := &DataProtectionKeychain{ + service: cfg.ServiceName, + + authenticationContext: authCtx, + accessControlFlags: accessControlFlags, + accessConstraint: accessConstraint, + } + + if kc.accessConstraint == 0 { + kc.accessConstraint = gokeychain.AccessibleWhenUnlockedThisDeviceOnly + } + + return kc, nil + }) +} + +func (k *DataProtectionKeychain) Get(key string) (Item, error) { + query := gokeychain.NewItem() + query.SetSecClass(gokeychain.SecClassGenericPassword) + query.SetService(k.service) + query.SetAccount(key) + query.SetMatchLimit(gokeychain.MatchLimitOne) + query.SetReturnAttributes(true) + query.SetReturnData(true) + query.SetUseDataProtectionKeychain(true) + + err := query.SetAuthenticationContext(k.authenticationContext) + if err != nil { + return Item{}, err + } + + debugf("Querying item in data protection keychain for service=%q, account=%q", k.service, key) + results, err := gokeychain.QueryItem(query) + + if err == gokeychain.ErrorItemNotFound || len(results) == 0 { + debugf("No results found") + return Item{}, ErrKeyNotFound + } + + if err != nil { + debugf("Error: %#v", err) + return Item{}, err + } + + item := Item{ + Key: key, + Data: results[0].Data, + Label: results[0].Label, + Description: results[0].Description, + } + + debugf("Found item %q", results[0].Label) + return item, nil +} + +func (k *DataProtectionKeychain) GetMetadata(key string) (Metadata, error) { + query := gokeychain.NewItem() + query.SetSecClass(gokeychain.SecClassGenericPassword) + query.SetService(k.service) + query.SetAccount(key) + query.SetMatchLimit(gokeychain.MatchLimitOne) + query.SetReturnAttributes(true) + query.SetReturnData(false) + query.SetReturnRef(true) + query.SetUseDataProtectionKeychain(true) + + err := query.SetAuthenticationContext(k.authenticationContext) + if err != nil { + return Metadata{}, err + } + + debugf("Querying keychain for metadata of service=%q, account=%q", k.service, key) + results, err := gokeychain.QueryItem(query) + if err == gokeychain.ErrorItemNotFound || len(results) == 0 { + debugf("No results found") + return Metadata{}, ErrKeyNotFound + } else if err != nil { + debugf("Error: %#v", err) + return Metadata{}, err + } + + md := Metadata{ + Item: &Item{ + Key: key, + Label: results[0].Label, + Description: results[0].Description, + }, + ModificationTime: results[0].ModificationDate, + } + + debugf("Found metadata for %q", md.Item.Label) + + return md, nil +} + +func (k *DataProtectionKeychain) updateItem(account string, data []byte) error { + queryItem := gokeychain.NewItem() + queryItem.SetSecClass(gokeychain.SecClassGenericPassword) + queryItem.SetService(k.service) + queryItem.SetAccount(account) + queryItem.SetMatchLimit(gokeychain.MatchLimitOne) + queryItem.SetReturnAttributes(true) + queryItem.SetUseDataProtectionKeychain(true) + + err := queryItem.SetAuthenticationContext(k.authenticationContext) + if err != nil { + return err + } + + results, err := gokeychain.QueryItem(queryItem) + if err != nil { + return fmt.Errorf("failed to query keychain: %v", err) + } + if len(results) == 0 { + return errors.New("no results") + } + + updateItem := gokeychain.NewItem() + updateItem.SetData(data) + + if err := gokeychain.UpdateItem(queryItem, updateItem); err != nil { + return fmt.Errorf("failed to update item in data protection keychain: %v", err) + } + + return nil +} + +func (k *DataProtectionKeychain) Set(item Item) error { + kcItem := gokeychain.NewItem() + kcItem.SetSecClass(gokeychain.SecClassGenericPassword) + kcItem.SetService(k.service) + kcItem.SetAccount(item.Key) + kcItem.SetLabel(item.Label) + kcItem.SetDescription(item.Description) + kcItem.SetData(item.Data) + kcItem.SetUseDataProtectionKeychain(true) + + if k.isSynchronizable && !item.KeychainNotSynchronizable { + kcItem.SetSynchronizable(gokeychain.SynchronizableYes) + } + + kcItem.SetAccessControl(k.accessControlFlags, k.accessConstraint) + + debugf("Adding service=%q, label=%q, account=%q", k.service, item.Label, item.Key) + + err := gokeychain.AddItem(kcItem) + + if err == gokeychain.ErrorDuplicateItem { + debugf("Item already exists, updating item service=%q, account=%q", k.service, item.Key) + err = k.updateItem(item.Key, item.Data) + } + + if err != nil { + return err + } + + return nil +} + +func (k *DataProtectionKeychain) Remove(key string) error { + item := gokeychain.NewItem() + item.SetSecClass(gokeychain.SecClassGenericPassword) + item.SetService(k.service) + item.SetAccount(key) + item.SetUseDataProtectionKeychain(true) + + debugf("Removing keychain item service=%q, account=%q", k.service, key) + err := gokeychain.DeleteItem(item) + if err == gokeychain.ErrorItemNotFound { + return ErrKeyNotFound + } + + if err != nil { + return fmt.Errorf("failed to delete item from data protection keychain: %v", err) + } + + return nil +} + +func (k *DataProtectionKeychain) Keys() ([]string, error) { + query := gokeychain.NewItem() + query.SetSecClass(gokeychain.SecClassGenericPassword) + query.SetService(k.service) + query.SetMatchLimit(gokeychain.MatchLimitAll) + query.SetReturnAttributes(true) + query.SetUseDataProtectionKeychain(true) + + err := query.SetAuthenticationContext(k.authenticationContext) + if err != nil { + return nil, err + } + + debugf("Querying keys in data protection keychain for service=%q", k.service) + results, err := gokeychain.QueryItem(query) + if err != nil { + return nil, err + } + + debugf("Found %d results", len(results)) + + accountNames := make([]string, len(results)) + for idx, r := range results { + accountNames[idx] = r.Account + } + + return accountNames, nil +} + +func mapStringsToFlags(strings []string) (gokeychain.AccessControlFlags, error) { + var flags gokeychain.AccessControlFlags + + flagMap := map[string]gokeychain.AccessControlFlags{ + "UserPresence": gokeychain.AccessControlFlagsUserPresence, + "BiometryAny": gokeychain.AccessControlFlagsBiometryAny, + "BiometryCurrentSet": gokeychain.AccessControlFlagsBiometryCurrentSet, + "DevicePasscode": gokeychain.AccessControlFlagsDevicePasscode, + "Watch": gokeychain.AccessControlFlagsWatch, + "Or": gokeychain.AccessControlFlagsOr, + "And": gokeychain.AccessControlFlagsAnd, + "PrivateKeyUsage": gokeychain.AccessControlFlagsPrivateKeyUsage, + "ApplicationPassword": gokeychain.AccessControlFlagsApplicationPassword, + } + + for _, flagString := range strings { + if flag, exists := flagMap[flagString]; exists { + flags |= flag // Combine flags using bitwise OR + } else { + return 0, fmt.Errorf("invalid access control flag: %s", flagString) + } + } + + return flags, nil +} + +func mapConstraint(constraint string) (gokeychain.Accessible, error) { + switch constraint { + case "AccessibleWhenUnlocked": + return gokeychain.AccessibleWhenUnlocked, nil + case "AccessibleAfterFirstUnlock": + return gokeychain.AccessibleAfterFirstUnlock, nil + case "AccessibleAfterFirstUnlockThisDeviceOnly": + return gokeychain.AccessibleAfterFirstUnlockThisDeviceOnly, nil + case "AccessibleWhenPasscodeSetThisDeviceOnly": + return gokeychain.AccessibleWhenPasscodeSetThisDeviceOnly, nil + case "AccessibleWhenUnlockedThisDeviceOnly": + return gokeychain.AccessibleWhenUnlockedThisDeviceOnly, nil + // @deprecated + // https://developer.apple.com/documentation/security/ksecattraccessiblealwaysthisdeviceonly + // https://developer.apple.com/documentation/security/ksecattraccessiblealways + case "AccessibleAccessibleAlwaysThisDeviceOnly": + case "AccessibleAlways": + return 0, fmt.Errorf("AccessibleAlways and AccessibleAccessibleAlwaysThisDeviceOnly have been deprecated, use AccessibleWhenUnlockedThisDeviceOnly instead") + case "": + return gokeychain.AccessibleDefault, nil + default: + return 0, fmt.Errorf("invalid access constraint: %s", constraint) + } + + return 0, nil +} diff --git a/go.mod b/go.mod index a9ebba4..480be34 100644 --- a/go.mod +++ b/go.mod @@ -8,15 +8,17 @@ 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/keybase/go-keychain v0.0.0-20231219164618-57a3676c3af6 github.com/mtibben/percent v0.2.1 - github.com/stretchr/testify v1.7.0 + github.com/stretchr/testify v1.8.4 golang.org/x/sys v0.3.0 golang.org/x/term v0.3.0 ) +replace github.com/keybase/go-keychain => github.com/alexw23/go-keychain v0.0.0-20240507145345-41efe171240e + require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/stretchr/objx v0.3.0 // indirect - gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index b7726bf..92e02bc 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs= github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4= +github.com/alexw23/go-keychain v0.0.0-20240507145345-41efe171240e h1:tr4NMs+H918AUrqOpYkjfeZDg7554ufuQXS/Ego/JRU= +github.com/alexw23/go-keychain v0.0.0-20240507145345-41efe171240e/go.mod h1:3VeWNIJaW+O5xpRQbPp0Ybqu1vJd/pm7s2F473HRrkw= github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0= github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -21,11 +23,10 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLA 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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.3.0 h1:NGXK3lHquSN08v5vWalVI/L8XU9hdzE/G6xsrze47As= -github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -35,5 +36,5 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 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= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/keyring.go b/keyring.go index 12161b7..cb0b007 100644 --- a/keyring.go +++ b/keyring.go @@ -12,14 +12,15 @@ type BackendType string // All currently supported secure storage backends. const ( - InvalidBackend BackendType = "" - SecretServiceBackend BackendType = "secret-service" - KeychainBackend BackendType = "keychain" - KeyCtlBackend BackendType = "keyctl" - KWalletBackend BackendType = "kwallet" - WinCredBackend BackendType = "wincred" - FileBackend BackendType = "file" - PassBackend BackendType = "pass" + InvalidBackend BackendType = "" + SecretServiceBackend BackendType = "secret-service" + KeychainBackend BackendType = "keychain" + DataProtectionKeychainBackend BackendType = "dp-keychain" + KeyCtlBackend BackendType = "keyctl" + KWalletBackend BackendType = "kwallet" + WinCredBackend BackendType = "wincred" + FileBackend BackendType = "file" + PassBackend BackendType = "pass" ) // This order makes sure the OS-specific backends @@ -29,6 +30,7 @@ var backendOrder = []BackendType{ WinCredBackend, // MacOS KeychainBackend, + DataProtectionKeychainBackend, // Linux SecretServiceBackend, KWalletBackend,