Skip to content

Commit

Permalink
Handle locked collections/items
Browse files Browse the repository at this point in the history
Handle get/store/erase on locked collections and items. When an item is
locked, we typically get a "prompt" dbus object that we need to call the
"Prompt" method on. This causes the Secret Service (gnome-keyring) to
pop up a window prompting the user for the password to unlock the item.

Use a Go 1.23 range/iterator function to iterate over the search results
when looking up a secret. This handles the unlocked and locked results
transparently, unlocking any matching locked items found. The iterator
also returns only the items that match the attributes exactly. This
makes the implementation of Get and Delete simpler.

Bump the "go" version in `go.mod` so that we can use range functions.
  • Loading branch information
camh- committed Sep 1, 2024
1 parent bb6d5ea commit be2bb05
Show file tree
Hide file tree
Showing 2 changed files with 157 additions and 42 deletions.
197 changes: 156 additions & 41 deletions dbus.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
package main

import (
"errors"
"fmt"
"os"
"iter"
"strings"

"github.com/godbus/dbus/v5"
Expand Down Expand Up @@ -105,36 +106,22 @@ func (ss *SecretService) Close() error {
// [items]: https://specifications.freedesktop.org/secret-service-spec/0.2/ch03.html
// [collection]: https://specifications.freedesktop.org/secret-service-spec/0.2/ch03.html
func (ss *SecretService) Get(attrs map[string]string) (string, error) {
unlocked, locked, err := ss.search(attrs)
results, err := ss.searchExact(attrs)
if err != nil {
return "", err
}

// Find the first item with an exact attribute match. Sometimes
// attrs may be a subset of attributes that have been stored (e.g.
// may not contain a path), and we want to skip those. We return
// the secret of the first one found that matches.
for _, item := range unlocked {
ok, err := ss.attrsMatch(attrs, item)
for itemPath, err := range results {
if err != nil {
// We could continue to the next item but errors
// should not happen here, so lets surface them early.
return "", err
}
if !ok {
continue
}
secret, err := ss.getSecret(item)
secret, err := ss.getSecret(itemPath)
if err != nil {
return "", err
}
return string(secret.Secret), nil
}

if len(locked) > 0 {
fmt.Fprintln(os.Stderr, "TODO: Found locked secret. Sorry, can't unlock yet")
}

return "", nil
}

Expand All @@ -155,7 +142,23 @@ func (ss *SecretService) Store(label string, attrs map[string]string, secret str
ContentType: "text/plain",
}

var itemPath, promptPath dbus.ObjectPath
// Try to unlock the collection first. Will be a no-op if it is not locked
// but if it is locked, we'll prompt the user to unlock it.
_, promptPath, err := ss.unlock([]dbus.ObjectPath{path})
if err != nil {
return err
}

if promptPath != dbus.ObjectPath("/") {
ok, err := ss.prompt(promptPath)
if err != nil {
return err
} else if !ok {
return errors.New("unlock cancelled by user")
}
}

var itemPath dbus.ObjectPath
call := collection.Call("org.freedesktop.Secret.Collection.CreateItem", 0, props, &sec, true)
if err := call.Store(&itemPath, &promptPath); err != nil {
return fmt.Errorf("couldn't create secret: %w", err)
Expand Down Expand Up @@ -183,31 +186,16 @@ func (ss *SecretService) Store(label string, attrs map[string]string, secret str
// the selected item occurs, or the secret cannot be deleted, an error is
// returned.
func (ss *SecretService) Delete(attrs map[string]string, expectedPassword string) error {
unlocked, locked, err := ss.search(attrs)
results, err := ss.searchExact(attrs)
if err != nil {
return err
}

// Find the first item with an exact attribute match. Sometimes
// attrs may be a subset of attributes that have been stored (e.g.
// may not contain a path), and we want to skip those. Ensure that
// expectedSecret matches the stored secret value
// the secret of the first one found that matches.
var itemPath dbus.ObjectPath
for _, item := range unlocked {
ok, err := ss.attrsMatch(attrs, item)
for item, err := range results {
if err != nil {
// We could continue to the next item but errors
// should not happen here, so lets surface them early.
return err
}
if !ok {
continue
}
// We will only erase the secret when presented with a password if the password
// stored in the secret matches that password. A secret can contain multiple
// fields separated by newlines. The password is the part before the first
// newline if there is one at all.
if expectedPassword != "" {
secret, err := ss.getSecret(item)
if err != nil {
Expand All @@ -222,9 +210,6 @@ func (ss *SecretService) Delete(attrs map[string]string, expectedPassword string
break
}

if !itemPath.IsValid() && len(locked) > 0 {
fmt.Fprintln(os.Stderr, "TODO: Found locked secret. Sorry, can't unlock for erase yet")
}
if !itemPath.IsValid() {
return nil
}
Expand All @@ -236,13 +221,72 @@ func (ss *SecretService) Delete(attrs map[string]string, expectedPassword string
return err
}

if promptPath != dbus.ObjectPath("/") {
fmt.Fprintln(os.Stderr, "TODO: Got prompt on delete. Sorry, can't do that yet")
if promptPath == dbus.ObjectPath("/") {
// no prompt. we're done now
return nil
}

ok, err := ss.prompt(promptPath)
if err != nil {
return err
} else if !ok {
return errors.New("unlock cancelled by user")
}

return nil
}

// searchExact returns a function iterator that iterates all the items in the
// SecretService that exactly match the given attributes. This is a more strict
// search than the [SearchItems] method of the service in that the items
// returned by the iterator will have only the given attribute and no extras.
//
// The iterator returns the item object path as the key and an error if the
// item's attributes could not be retrieved.
//
// e.g.
//
// results, err := ss.searchExact(attrs) {
// if err != nil {
// return err
// }
// for itemPath, err := results {
// if err != nil {
// return err
// }
// // .. do something with itemPath
// }
//
// [SearchItems]: https://specifications.freedesktop.org/secret-service-spec/latest/org.freedesktop.Secret.Service.html#org.freedesktop.Secret.Service.SearchItems
func (ss *SecretService) searchExact(attrs map[string]string) (iter.Seq2[dbus.ObjectPath, error], error) {
unlocked, locked, err := ss.search(attrs)
if err != nil {
return nil, err
}
f := func(yield func(item dbus.ObjectPath, err error) bool) {
for _, itemPath := range unlocked {
ok, err := ss.attrsMatch(attrs, itemPath)
if !ok && err == nil {
continue
}
if !yield(itemPath, err) {
return
}
}
for _, itemPath := range locked {
ok, err := ss.attrsMatch(attrs, itemPath)
if !ok && err == nil {
continue
}
itemPath, err = ss.unlockItem(itemPath)
if !yield(itemPath, err) {
return
}
}
}
return f, nil
}

// search returns all the unlocked and locked secret items that match the given
// attributes. If the DBus call fails, an error is returned.
func (ss *SecretService) search(attrs map[string]string) (unlocked, locked []dbus.ObjectPath, err error) {
Expand Down Expand Up @@ -288,3 +332,74 @@ func (ss *SecretService) attrsMatch(attrs map[string]string, itemPath dbus.Objec
}
return true, nil
}

func (ss *SecretService) unlockItem(itemPath dbus.ObjectPath) (dbus.ObjectPath, error) {
unlocked, promptPath, err := ss.unlock([]dbus.ObjectPath{itemPath})
if err != nil {
return "", err
}

if len(unlocked) > 0 {
// we'll never get back more than 1 item in the slice
return unlocked[0], nil
}

if promptPath == dbus.ObjectPath("/") {
return "", fmt.Errorf("huh? no item or prompt when unlocking: %v", itemPath)
}

if ok, err := ss.prompt(promptPath); err != nil {
return "", err
} else if !ok {
return "", errors.New("unlock cancelled by user")
}
return itemPath, nil
}

// unlock attempts to unlock the objects given and returns the paths for the
// objects that were unlocked and a prompt path to unlock the remainder.
func (ss *SecretService) unlock(objects []dbus.ObjectPath) (unlocked []dbus.ObjectPath, prompt dbus.ObjectPath, err error) {
svc := ss.conn.Object("org.freedesktop.secrets", dbus.ObjectPath("/org/freedesktop/secrets"))
call := svc.Call("org.freedesktop.Secret.Service.Unlock", 0, objects)
err = call.Store(&unlocked, &prompt)
return
}

// prompt calls Prompt on the prompt object at the given path and waits for the
// Completed signal to be emitted from it. It returns true if the prompt was
// completed, or false if it was cancelled. If an error occurs subscribing to
// the signal or calling the prompt object, it is returned instead.
func (ss *SecretService) prompt(path dbus.ObjectPath) (bool, error) {
// Subscribe to signals on the prompt object so we can get the
// "Completed" signal when the prompt is complete. We do this
// before calling Prompt to ensure we do not miss it. Only one
// signal should ever arrive on the channel, so make it a
// buffererd channel of size 1 so the dbus library wont drop
// the signal.
ch := make(chan *dbus.Signal, 1)
ss.conn.Signal(ch)
defer ss.conn.RemoveSignal(ch)
if err := ss.conn.AddMatchSignal(dbus.WithMatchObjectPath(path)); err != nil {
return false, err
}
defer ss.conn.RemoveMatchSignal(dbus.WithMatchObjectPath(path)) //nolint:errcheck

svc := ss.conn.Object("org.freedesktop.secrets", path)
call := svc.Call("org.freedesktop.Secret.Prompt.Prompt", 0, "")
if call.Err != nil {
return false, call.Err
}

cancelled := false
for sig := range ch {
if sig.Name != "org.freedesktop.Secret.Prompt.Completed" {
continue
}
var unlockPaths []dbus.ObjectPath
if err := dbus.Store(sig.Body, &cancelled, &unlockPaths); err != nil {
return false, err
}
break
}
return !cancelled, nil
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module foxygo.at/git-credential-fdoss

go 1.22.5
go 1.23

require (
github.com/alecthomas/kong v0.9.0
Expand Down

0 comments on commit be2bb05

Please sign in to comment.