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.
261 lines
7.6 KiB
Go
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
|
|
}
|