diff --git a/.golangci.yaml b/.golangci.yaml index ead7e4a..2ebd8ed 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -4,6 +4,11 @@ issues: - "^don't use ALL_CAPS" - "^ST1003: should not use ALL_CAPS" - "^G304: Potential file inclusion via variable" + exclude-rules: + # Dynamic errors are OK in main (top-level dir). Packages should use sentinels. + - path: "^[^/]*$" + linters: + - err113 linters: enable-all: true diff --git a/Makefile b/Makefile index 7c0ab36..a13fba4 100644 --- a/Makefile +++ b/Makefile @@ -24,8 +24,13 @@ clean:: .PHONY: all check-uptodate ci clean # --- Build -------------------------------------------------------------------- +# git credential helpers need to be called git-credential- for git to +# find it when the config is set up to use the credential helper. BIN_NAME = git-credential-fdoss -GO_TAGS = +# We want a statically linked binary. github.com/godbus/dbus/v5 imports "net" +# and "os/user", so specify the build tags to use the Go versions of these and +# not the libc ones so that we get a static binary. +GO_TAGS = netgo,osusergo GO_LDFLAGS = -X main.version=$(VERSION) GO_FLAGS += $(if $(GO_TAGS),-tags='$(GO_TAGS)') GO_FLAGS += $(if $(GO_LDFLAGS),-ldflags='$(GO_LDFLAGS)') @@ -49,7 +54,15 @@ lint: .PHONY: lint -# --- Release ------------------------------------------------------------------- +# --- Docs --------------------------------------------------------------------- + +godoc: build + ./bin/gengodoc.awk main.go > $(O)/out.go + mv $(O)/out.go main.go + +.PHONY: godoc + +# --- Release ------------------------------------------------------------------ RELEASE_DIR = $(O)/release ## Tag and release binaries for different OS on GitHub release diff --git a/bin/gengodoc.awk b/bin/gengodoc.awk new file mode 100755 index 0000000..aeae868 --- /dev/null +++ b/bin/gengodoc.awk @@ -0,0 +1,12 @@ +#!/usr/bin/env -S awk -f +$0 ~ "//\tUsage:" { + in_usage = 1 + cmd = $3 +} +$0 ~ "^(package |// [[]|^$)" && in_usage { + system("out/" cmd " --help | sed -e '/./s|^|//\t|' -e 's|^$|//|'") + if ($1 == "//") printf "//\n" + in_usage = 0 +} + +!in_usage { print } diff --git a/dbus.go b/dbus.go new file mode 100644 index 0000000..abd1dc8 --- /dev/null +++ b/dbus.go @@ -0,0 +1,290 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/godbus/dbus/v5" +) + +// SecretService implements a client of the freedesktop.org DBus [Secret +// Service Specification] implementing only the functionality needed for an +// implementation of a git credential helper. It is not a full client +// implementation of the DBus API. +// +// It supports adding secrets, looking them up and deleting them, mapping to +// the "store", "get" and "erase" commands of the git-credential protocol. +// +// [Secret Service Specification]: https://specifications.freedesktop.org/secret-service-spec/latest +type SecretService struct { + conn *dbus.Conn + svc dbus.BusObject + session dbus.BusObject +} + +// Secret is a struct compatible with the [Secret] struct type as defined in +// the specification. This Go struct uses types that are marshalable by the +// dbus library into the correct wire format - no struct tags or other magic +// needed. +// +// [Secret]: https://specifications.freedesktop.org/secret-service-spec/latest/types.html#type-Secret +type Secret struct { + Session dbus.ObjectPath + Params []byte + Secret []byte + ContentType string +} + +// NewSecretService constructs and returns a SecretService for acting as a +// client on the DBus session bus to the Secret Service. It establishes a +// [session] with the secret service. The session is configured only with +// "plain" encryption currently (i.e. no encryption). Encrypted sessions are +// still to be implemented. +// +// If the connection to DBus could not be established or if the secret service +// session could not be created, an error is returned instead. +// +// [session]: https://specifications.freedesktop.org/secret-service-spec/0.2/sessions.html +func NewSecretService() (*SecretService, error) { + conn, err := dbus.SessionBus() + if err != nil { + return nil, fmt.Errorf("couldn't connect to session bus: %w", err) + } + svc := conn.Object("org.freedesktop.secrets", dbus.ObjectPath("/org/freedesktop/secrets")) + + var path dbus.ObjectPath + var output dbus.Variant + call := svc.Call("org.freedesktop.Secret.Service.OpenSession", 0, "plain", dbus.MakeVariant("")) + if err := call.Store(&output, &path); err != nil { + return nil, fmt.Errorf("couldn't open secret session: %w", err) + } + + session := conn.Object("org.freedesktop.secrets", path) + + return &SecretService{ + conn: conn, + svc: svc, + session: session, + }, nil +} + +// Close closes the session with the secret service, making it no longer +// possible to deal with secret data with the service. It is not necessary to +// close the session as the secret service will be notified when the client +// disconnects from the bus. +func (ss *SecretService) Close() error { + call := ss.session.Call("org.freedesktop.Secret.Session.Close", 0) + return call.Err +} + +// Get looks up [items] in the default [collection] by the given set of +// attributes and returns the secret of the first item that matches those +// attributes. If there are no matches, an empty string is returned, signifying +// no password was found. +// +// The attributes are a set of arbitrary name/value strings that were provided +// when the secret was stored. +// +// Only secrets that match on all attributes and have no extra attributes are +// considered. If there are multiple exact matches, the first is returned. It +// is not clear what the ordering of the secrets is, so the "first" secret may +// be arbitrary. However, it should not be possible to have multiple secrets +// with the same attribues so this should not happen. +// +// Currently only unlocked secrets can be returned. If only a locked secret +// matches the attributes, a diagnostic error will be printed to stderr and no +// secret will be returned. +// +// If an error looking up the items identified by the attributes occurs or an +// error returning the secret for the selected item occurs, an empty string is +// returned. +// +// See makeAttrs() for the attributes used by git-credential-fdoss. +// +// [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) + 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) + 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) + 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 +} + +// Store stores a secret with the secret service using the given descriptive +// label, a set of key/value string attributes for looking up the secret and +// the actual secret value. The secret is stored in the default collection. If +// the secret could not be created, an error is returned. +func (ss *SecretService) Store(label string, attrs map[string]string, secret string) error { + path := dbus.ObjectPath("/org/freedesktop/secrets/aliases/default") + collection := ss.conn.Object("org.freedesktop.secrets", path) + props := map[string]dbus.Variant{ + "org.freedesktop.Secret.Item.Label": dbus.MakeVariant(label), + "org.freedesktop.Secret.Item.Attributes": dbus.MakeVariant(attrs), + } + sec := Secret{ + Session: ss.session.Path(), + Secret: []byte(secret), + ContentType: "text/plain", + } + + var itemPath, promptPath 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) + } + return nil +} + +// Delete removes a secret matching the given attributes. If expectedPassword +// is not empty, then the secret matching the attributes will only be removed +// if the password in the value of the secret stored matches expectedPassword. +// If expectedPassword is empty, then the secret will be removed if it just +// matches the attributes. +// +// Only secrets that match on all attributes and have no extra attributes are +// considered. If there are multiple exact matches, the first is returned. It +// is not clear what the ordering of the secrets is, so the "first" secret may +// be arbitrary. However, it should not be possible to have multiple secrets +// with the same attribues so this should not happen. +// +// Currently only unlocked secrets can be deleted. If only a locked secret +// matches the attributes, a diagnostic error will be printed to stderr and no +// secret will be deleted. +// +// If an error looking up the items occurs, an error returning the secret for +// 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) + 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) + 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 { + return err + } + password, _, _ := strings.Cut(string(secret.Secret), "\n") + if password != expectedPassword { + continue + } + } + itemPath = item + 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 + } + + item := ss.conn.Object("org.freedesktop.secrets", itemPath) + call := item.Call("org.freedesktop.Secret.Item.Delete", 0) + var promptPath dbus.ObjectPath + if err := call.Store(&promptPath); err != nil { + return err + } + + if promptPath != dbus.ObjectPath("/") { + fmt.Fprintln(os.Stderr, "TODO: Got prompt on delete. Sorry, can't do that yet") + } + + return 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) { + svc := ss.conn.Object("org.freedesktop.secrets", dbus.ObjectPath("/org/freedesktop/secrets")) + call := svc.Call("org.freedesktop.Secret.Service.SearchItems", 0, attrs) + err = call.Store(&unlocked, &locked) + return +} + +// getSecret returns the secret struct for the given item path, or an error if +// the DBus call fails. +func (ss *SecretService) getSecret(itemPath dbus.ObjectPath) (secret Secret, err error) { + item := ss.conn.Object("org.freedesktop.secrets", itemPath) + call := item.Call("org.freedesktop.Secret.Item.GetSecret", 0, ss.session.Path()) + err = call.Store(&secret) + return +} + +// attrsMatch returns true if the given items have exactly the given +// attributes. If the item has extra or fewer attributes, or any values are +// different, false is returned. If the attributes of the item could be +// retrieved an error is returned. +func (ss *SecretService) attrsMatch(attrs map[string]string, itemPath dbus.ObjectPath) (bool, error) { + item := ss.conn.Object("org.freedesktop.secrets", itemPath) + prop, err := item.GetProperty("org.freedesktop.Secret.Item.Attributes") + if err != nil { + return false, err + } + + itemAttrs, ok := prop.Value().(map[string]string) + if !ok { + return false, fmt.Errorf("item attributes property is not a map: %v", itemPath) + } + + if len(itemAttrs) != len(attrs) { + return false, nil + } + for k, v1 := range attrs { + v2, ok := itemAttrs[k] + if !ok || v1 != v2 { + return false, nil + } + } + return true, nil +} diff --git a/gitcred.go b/gitcred.go new file mode 100644 index 0000000..3683a7b --- /dev/null +++ b/gitcred.go @@ -0,0 +1,136 @@ +package main + +import ( + "bufio" + "errors" + "fmt" + "io" + "net" + "strconv" + "strings" +) + +// GitCredential is a Go struct form of a credential used in the +// [git-credential] protocol. It can be unmarshal from an [io.Reader] and +// marshaled to an [io.Writer]. +// +// The git-credential protocol is a simple line-based key/value pair text +// protocol. A simple example for storing a secret is: +// +// protocol=https +// host=example.com +// username=bob +// password=secr3t +// +// A similar input without the "password" field would be used to retrieve a +// secret. +// +// [git-credential]: https://git-scm.com/docs/git-credential#IOFMT +type GitCredential struct { + Protocol string + Host string + Port uint16 + Path string + Username string + Password string + PasswordExpiryUTC string + OauthRefreshToken string + URL string + WWWAuth []string +} + +// Unmarshal reads a git credential in git-credential wire format into the +// GitCredential receiver. The "host" on the wire has the port split off if +// there is one there. If there is not, the Port field will contain 0. Other +// than that, no fields are interpreted as anything other than a string. This +// is largely as a git-credential helper does not need to concern itself with +// the content of the message (i.e. the "password_expiry_utc" field does not +// need to be interpreted as a date to store or retrieve credentials). +// +// Any unknown fields are ignored. +// +// If there are no errors, nil is returned. If an input line cannot be +// processed, an error is returned. +// +// Unmarshal will stop reading from the given io.Reader if it encounters an +// error on a line, a blank line is read, or EOF is reached. +func (gc *GitCredential) Unmarshal(r io.Reader) error { + scanner := bufio.NewScanner(r) + // Scan until EOF or a blank line + for scanner.Scan() && scanner.Text() != "" { + k, v, ok := strings.Cut(scanner.Text(), "=") + if !ok { + return fmt.Errorf("malformed input line: missing '=': %s", scanner.Text()) + } + switch k { + case "protocol": + gc.Protocol = v + case "host": + h, p, err := net.SplitHostPort(v) + var ae *net.AddrError + if errors.As(err, &ae) && ae.Err == "missing port in address" { + gc.Host = v + continue + } + if err != nil { + return err + } + gc.Host = h + if p != "" { + i, err := strconv.ParseUint(p, 10, 16) + if err != nil { + return err + } + gc.Port = uint16(i) + } + case "path": + gc.Path = v + case "username": + gc.Username = v + case "password": + gc.Password = v + case "password_expiry_utc": + gc.PasswordExpiryUTC = v + case "oauth_refresh_token": + gc.OauthRefreshToken = v + case "url": + gc.URL = v + case "wwwauth[]": + if v == "" { + gc.WWWAuth = nil + } else { + gc.WWWAuth = append(gc.WWWAuth, v) + } + default: + // Ignore unknown fields for forward compatibility + } + } + return nil +} + +// Marshal writes the contents of the GitCredential receiver to the given +// io.Writer in git-credential wire format. Any empty fields of the receiver +// are ignored, as is a zero Port. +// +// If there is a error writing to the io.Writer, no further fields are written +// and the error is returned. If there is no error, nil is returned. +func (gc *GitCredential) Marshal(w io.Writer) error { + var err error + marshal := func(k, v string) { + if err == nil && v != "" { + _, err = fmt.Fprintf(w, "%s=%s\n", k, v) + } + } + marshal("protocol", gc.Protocol) + marshal("host", gc.Host) + marshal("path", gc.Path) + marshal("username", gc.Username) + marshal("password", gc.Password) + marshal("password_expiry_utc", gc.PasswordExpiryUTC) + marshal("oauth_refresh_token", gc.OauthRefreshToken) + marshal("url", gc.URL) + for _, v := range gc.WWWAuth { + marshal("wwwauth[]", v) + } + return err +} diff --git a/go.mod b/go.mod index 53fffdd..2bb9d83 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,7 @@ module foxygo.at/git-credential-fdoss go 1.22.5 -require github.com/alecthomas/kong v0.9.0 +require ( + github.com/alecthomas/kong v0.9.0 + github.com/godbus/dbus/v5 v5.1.0 +) diff --git a/go.sum b/go.sum index 524151d..dad0cd2 100644 --- a/go.sum +++ b/go.sum @@ -4,5 +4,7 @@ github.com/alecthomas/kong v0.9.0 h1:G5diXxc85KvoV2f0ZRVuMsi45IrBgx9zDNGNj165aPA github.com/alecthomas/kong v0.9.0/go.mod h1:Y47y5gKfHp1hDc7CH7OeXgLIpp+Q2m1Ni0L5s3bI8Os= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= diff --git a/main.go b/main.go index a17000c..821a2a5 100644 --- a/main.go +++ b/main.go @@ -1,8 +1,36 @@ // cmd git-credential-fdoss is a git credentials helper that uses the // freedesktop.org secret service for storing and retrieving git credentials. +// +// Usage: git-credential-fdoss [flags] +// +// git-credential-fdoss manages your git credentials using the freedesktop.org +// Secret Service. +// +// Flags: +// -h, --help Show context-sensitive help. +// -V, --version Print program version +// +// Commands: +// get [flags] +// Get credentials from keyring +// +// store [flags] +// Save credentials to keyring +// +// erase [flags] +// Erase credentials from keyring +// +// Run "git-credential-fdoss --help" for more information on a command. package main import ( + "bufio" + "errors" + "fmt" + "os" + "strconv" + "strings" + "github.com/alecthomas/kong" ) @@ -14,9 +42,21 @@ Secret Service. ` type CLI struct { + Get CmdGet `cmd:"" help:"Get credentials from keyring"` + Store CmdStore `cmd:"" help:"Save credentials to keyring"` + Erase CmdErase `cmd:"" help:"Erase credentials from keyring"` + Version kong.VersionFlag `short:"V" help:"Print program version"` } +type validator struct{ err error } + +type ( + CmdGet struct{ validator } + CmdStore struct{ validator } + CmdErase struct{ validator } +) + func main() { cli := &CLI{} kctx := kong.Parse(cli, @@ -26,3 +66,212 @@ func main() { err := kctx.Run(cli) kctx.FatalIfErrorf(err) } + +// validate is a helper function to simplify validing input fields for the +// CLI commands. It saves the first error encountered, and is a no-op for +// calls after the first error is recorded. +func (v *validator) validate(ok bool, errmsg string) { + if v.err != nil { + return + } + if !ok { + v.err = fmt.Errorf("input field %s must be set", errmsg) + } +} + +// Run cleans up after a command. It is called after any commands are run. +func (cmd *CLI) Run(ss *SecretService) error { + // This close is not strictly necessary as the session is closed + // automatically when the caller goes away, but it is here to capture + // errors for debugging and understanding. + return ss.Close() +} + +// AfterApply on CLI runs before AfterApply of any commands, creating a +// GitCredential from stdin and opening a connection to DBus and creating +// a session with the secret service. These two resources are bound to +// the kong context to make them available to the command Run methods. +func (cmd *CLI) AfterApply(kctx *kong.Context) error { + // Create a GitCredential from the lines on stdin. See + // git-credential(1) for the format. + // https://git-scm.com/docs/git-credential#IOFMT + gc := &GitCredential{} + if err := gc.Unmarshal(os.Stdin); err != nil { + return err + } + kctx.Bind(gc) + + // Open a DBus connection and create a session with the secret service. + // https://specifications.freedesktop.org/secret-service/latest/ + ss, err := NewSecretService() + if err != nil { + return err + } + kctx.Bind(ss) + + return nil +} + +// AfterApply validates the input credential fields for a get command. +func (cmd *CmdGet) AfterApply(gc *GitCredential) error { + cmd.validate(gc.Protocol != "", "protocol") + cmd.validate(gc.Host != "" || gc.Path != "", "host or path") + return cmd.err +} + +// Run executes the credential helper "get" operation. +// +// The "get" operation is specified by the [gitcredentials] documentation, and +// exists to look up a password and/or other secret material to access a remote +// git repository, previously stored with a "store" operation. +// +// [gitcredentials]: https://git-scm.com/docs/gitcredentials +func (cmd *CmdGet) Run(gc *GitCredential, ss *SecretService) error { + secret, err := ss.Get(makeAttrs(gc)) + if err != nil { + return err + } + + if secret == "" { + return nil + } + + if err := parseSecretVal(secret, gc); err != nil { + return err + } + + return gc.Marshal(os.Stdout) +} + +// AfterApply validates the input credential fields for a store command. +func (cmd *CmdStore) AfterApply(gc *GitCredential) error { + cmd.validate(gc.Protocol != "", "protocol") + cmd.validate(gc.Username != "", "username") + cmd.validate(gc.Password != "", "password") + cmd.validate(gc.Host != "" || gc.Path != "", "host or path") + return cmd.err +} + +// Run executes the credential helper "store" operation. +// +// The "store" operation is specified by the [gitcredentials] documentation, +// and exists to store a password and/or other secret material needed to access +// a remote git repository. +// +// [gitcredentials]: https://git-scm.com/docs/gitcredentials +func (cmd *CmdStore) Run(gc *GitCredential, ss *SecretService) error { + return ss.Store(makeLabel(gc), makeAttrs(gc), formatSecretVal(gc)) +} + +// AfterApply validates the input credential fields for a erase command. +func (cmd *CmdErase) AfterApply(gc *GitCredential) error { + cmd.validate(gc.Protocol != "", "protocol") + cmd.validate(gc.Username != "", "username") + cmd.validate(gc.Host != "" || gc.Path != "", "host or path") + return cmd.err +} + +// Run executes the credential helper "erase" operation. +// +// The "erase" operation is specified by the [gitcredentials] documentation, +// and exists to delete a password and/or other secret material previously +// stored with a "store" operation. +// +// [gitcredentials]: https://git-scm.com/docs/gitcredentials +func (cmd *CmdErase) Run(gc *GitCredential, ss *SecretService) error { + return ss.Delete(makeAttrs(gc), gc.Password) +} + +// makeLabel returns a string describing the given GitCredential, used as a +// descriptive label for a secret stored with the secret service. The label +// is the same as created by git-credential-libsecret, although that is not +// required for compatibility. +func makeLabel(gc *GitCredential) string { + label := "Git: " + gc.Protocol + "://" + gc.Host + if gc.Port != 0 { + label += ":" + strconv.FormatUint(uint64(gc.Port), 10) + } + label += "/" + gc.Path + return label +} + +// makeAttrs maps the fields of GitCredentials to secret service attribues. The +// [mapping] is taken from git-credential-libsecret to be compatible with it. +// +// [mapping]: https://github.com/git/git/blob/159f2d50e75c17382c9f4eb7cbda671a6fa612d1/contrib/credential/libsecret/git-credential-libsecret.c#L90 +func makeAttrs(gc *GitCredential) map[string]string { + attrs := map[string]string{ + "xdg:schema": "org.git.Password", + } + if gc.Username != "" { + attrs["user"] = gc.Username + } + if gc.Protocol != "" { + attrs["protocol"] = gc.Protocol + } + if gc.Host != "" { + attrs["server"] = gc.Host + } + if gc.Port != 0 { + attrs["port"] = strconv.FormatUint(uint64(gc.Port), 10) + } + if gc.Path != "" { + attrs["object"] = gc.Path + } + return attrs +} + +// formatSecretVal encodes the secret and/or variable parts of a GitCredential +// into a string suitable for storing with the secret service. Variable parts, +// such as the password expiry time, cannot be encoded as an attribute as they +// need to match on lookup. +// +// The format for [encoding] multiple values is the same as used by +// git-credential-libsecret to be compatible with it. +// +// Note: This encoding is not compatible with the unencrypted keyring format of +// gnome-keyring. Gnome-keyring does not escape the newlines in secrets when +// storing them in an unencrypted keyring, and those newlines appear as field +// separators in the keyring. When the secret is read back, it is only read +// back to the first newline, so we don't get back all that we stored. +// +// It would not be typical to be using unencrypted keyrings though as it mostly +// defeats the purpose of a secret manager. If one did not care about +// unencrypted storage of git credentials, git-credential-store (bundled with +// git) would make more sense than this credential helper. +// +// [encoding]: https://github.com/git/git/blob/159f2d50e75c17382c9f4eb7cbda671a6fa612d1/contrib/credential/libsecret/git-credential-libsecret.c#L212 +func formatSecretVal(gc *GitCredential) string { + secret := gc.Password + if gc.PasswordExpiryUTC != "" { + secret += "\npassword_expiry_utc=" + gc.PasswordExpiryUTC + } + if gc.OauthRefreshToken != "" { + secret += "\noauth_refresh_token=" + gc.OauthRefreshToken + } + return secret +} + +// parseSecretVal extracts the fields encoded in a secret string into the given +// GitCredential. Unknown fields are ignored. An error is returned if there are +// any malformed fields that could not be extracted. +func parseSecretVal(secret string, gc *GitCredential) error { + scanner := bufio.NewScanner(strings.NewReader("password=" + secret)) + for scanner.Scan() { + k, v, ok := strings.Cut(scanner.Text(), "=") + if !ok { + return errors.New("malformed secret returned from secret service (missing '=')") + } + switch k { + case "password": + gc.Password = v + case "password_expiry_utc": + gc.PasswordExpiryUTC = v + case "oauth_refresh_token": + gc.OauthRefreshToken = v + default: + // Ignore unknown fields for forward compatibiilty + } + } + return nil +}