Files
mesh-catalog/modules/gnome-keyring/cmd/gnome-keyring-tools/keyring.go
T
jochen dc140d5345 gnome-keyring: the secret service as a module, claiming node-secret-service (hq ADR 0208, ADR 0102)
PAM lines written into login and passwd as blocks, so login unlocks the keyring
on both workstations; no daemon of its own; gcr's ssh agent named for the session
until the environment can say a runtime-directory path. Go tools unlocked, lock,
collections and ssh-keys, never reading a secret.
2026-10-04 13:10:58 +02:00

261 lines
7.6 KiB
Go

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
}