package main import ( "encoding/json" "fmt" "path/filepath" "regexp" "sort" "strconv" "strings" "time" ) const ( service = "org.freedesktop.secrets" servicePath = "/org/freedesktop/secrets" collectionDir = "/org/freedesktop/secrets/collection/" busTimeout = 10 * time.Second ) // busctl runs one busctl call on the account's session bus and answers its JSON. func busctl(s Session, args ...string) (json.RawMessage, error) { r, err := s.run(busTimeout, "", "busctl", append([]string{"--user", "--json=short"}, args...)...) if err != nil { return nil, err } if r.Code != 0 { return nil, fmt.Errorf("the secret service: %s", strings.TrimSpace(r.Stderr)) } var v struct { Data json.RawMessage `json:"data"` } if err := json.Unmarshal([]byte(r.Stdout), &v); err != nil { return nil, fmt.Errorf("busctl answered no JSON: %w", err) } return v.Data, nil } // collectionID is the part of a collection's object path after .../collection/, unescaped the way // the Secret Service escapes it ("_5f" is "_"). func collectionID(path string) string { return strings.TrimPrefix(path, collectionDir) } func collectionPath(id string) string { return collectionDir + id } var validID = regexp.MustCompile(`^[A-Za-z0-9_]+$`) // Collection is one keyring. type Collection struct { ID string `json:"id"` Label string `json:"label"` Locked bool `json:"locked"` Items int `json:"items"` Created string `json:"created,omitempty"` Modified string `json:"modified,omitempty"` Default bool `json:"default,omitempty"` } // CollectionsResult is what gnome_keyring_collections answers. type CollectionsResult struct { Collections []Collection `json:"collections"` } func paths(s Session) ([]string, error) { raw, err := busctl(s, "get-property", service, servicePath, "org.freedesktop.Secret.Service", "Collections") if err != nil { return nil, err } var out []string if err := json.Unmarshal(raw, &out); err != nil { return nil, fmt.Errorf("the collections: %w", err) } return out, nil } func defaultCollection(s Session) string { raw, err := busctl(s, "call", service, servicePath, "org.freedesktop.Secret.Service", "ReadAlias", "s", "default") if err != nil { return "" } var out []string if json.Unmarshal(raw, &out) != nil || len(out) == 0 || out[0] == "/" { return "" } return collectionID(out[0]) } // describe reads a collection's properties: label, lock, the number of items (never the items), times. func describe(s Session, path string) (Collection, error) { raw, err := busctl(s, "call", service, path, "org.freedesktop.DBus.Properties", "GetAll", "s", "org.freedesktop.Secret.Collection") if err != nil { return Collection{}, err } return parseCollection(path, raw) } func parseCollection(path string, raw json.RawMessage) (Collection, error) { var answer []map[string]struct { Data json.RawMessage `json:"data"` } if err := json.Unmarshal(raw, &answer); err != nil || len(answer) != 1 { return Collection{}, fmt.Errorf("collection %s: not a property map", path) } p := answer[0] c := Collection{ID: collectionID(path)} _ = json.Unmarshal(p["Label"].Data, &c.Label) _ = json.Unmarshal(p["Locked"].Data, &c.Locked) var items []string _ = json.Unmarshal(p["Items"].Data, &items) c.Items = len(items) for key, into := range map[string]*string{"Created": &c.Created, "Modified": &c.Modified} { var t int64 if json.Unmarshal(p[key].Data, &t) == nil && t > 0 { *into = time.Unix(t, 0).Format(time.RFC3339) } } return c, nil } // Collections are the operator's keyrings. func Collections() (CollectionsResult, error) { s, err := findBus() if err != nil { return CollectionsResult{}, err } ps, err := paths(s) if err != nil { return CollectionsResult{}, err } def := defaultCollection(s) out := CollectionsResult{Collections: []Collection{}} for _, p := range ps { c, err := describe(s, p) if err != nil { return CollectionsResult{}, err } c.Default = c.ID == def out.Collections = append(out.Collections, c) } sort.Slice(out.Collections, func(i, j int) bool { return out.Collections[i].ID < out.Collections[j].ID }) return out, nil } // UnlockedResult is what gnome_keyring_unlocked answers. type UnlockedResult struct { Daemon bool `json:"daemon_running"` Login *Collection `json:"login,omitempty"` Default *Collection `json:"default,omitempty"` Note string `json:"note,omitempty"` } // Unlocked is whether the login and default keyrings are unlocked. func Unlocked() (UnlockedResult, error) { s, err := findBus() if err != nil { return UnlockedResult{}, err } // gnome-keyring-daemon, as the kernel shortens a command's name to 15 characters. out := UnlockedResult{Daemon: len(processesOf("gnome-keyring-d")) > 0} if c, err := describe(s, collectionPath("login")); err == nil { out.Login = &c } else { out.Note = "no login keyring: " + err.Error() } if def := defaultCollection(s); def != "" && def != "login" { if c, err := describe(s, collectionPath(def)); err == nil { c.Default = true out.Default = &c } } else if out.Login != nil { out.Login.Default = def == "login" } return out, nil } // LockResult is what gnome_keyring_lock answers. type LockResult struct { Collection string `json:"collection"` Locked bool `json:"locked"` } // Lock locks one keyring. func Lock(id string) (LockResult, error) { if id == "" { id = "login" } if !validID.MatchString(id) { return LockResult{}, fmt.Errorf("collection %q is not a keyring id", id) } s, err := findBus() if err != nil { return LockResult{}, err } if _, err := busctl(s, "call", service, servicePath, "org.freedesktop.Secret.Service", "Lock", "ao", "1", collectionPath(id)); err != nil { return LockResult{}, err } c, err := describe(s, collectionPath(id)) if err != nil { return LockResult{}, err } return LockResult{Collection: id, Locked: c.Locked}, nil } // Key is one key the ssh agent holds. type Key struct { Bits int `json:"bits"` Fingerprint string `json:"fingerprint"` Comment string `json:"comment"` Type string `json:"type"` } // SSHKeysResult is what gnome_keyring_ssh_keys answers. type SSHKeysResult struct { Agent string `json:"agent"` Keys []Key `json:"keys"` Note string `json:"note,omitempty"` } // agentSocket is gcr's ssh agent socket, which its user socket unit listens on. func agentSocket(s Session) string { return filepath.Join(s.RuntimeDir, "gcr", "ssh") } var keyLine = regexp.MustCompile(`^(\d+)\s+(\S+)\s+(.*?)\s*\(([A-Z0-9-]+)\)$`) func parseKeys(out string) []Key { keys := []Key{} for _, line := range strings.Split(out, "\n") { m := keyLine.FindStringSubmatch(strings.TrimSpace(line)) if m == nil { continue } bits, _ := strconv.Atoi(m[1]) keys = append(keys, Key{Bits: bits, Fingerprint: m[2], Comment: m[3], Type: m[4]}) } return keys } // SSHKeys lists what gcr's ssh agent holds, by fingerprint. func SSHKeys() (SSHKeysResult, error) { s, err := findBus() if err != nil { return SSHKeysResult{}, err } sock := agentSocket(s) // The agent is named for this one command only; nothing else of the tool's environment changes. r, err := s.run(busTimeout, "", "env", "SSH_AUTH_SOCK="+sock, "ssh-add", "-l", "-E", "sha256") if err != nil { return SSHKeysResult{}, err } out := SSHKeysResult{Agent: sock, Keys: parseKeys(r.Stdout)} switch r.Code { case 0: case 1: out.Note = "the agent holds no keys" case 127: return SSHKeysResult{}, fmt.Errorf("ssh-add is not installed on this machine") default: return SSHKeysResult{}, fmt.Errorf("the ssh agent at %s does not answer: %s (is gcr-ssh-agent.socket enabled?)", sock, strings.TrimSpace(r.Stderr)) } return out, nil }