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.
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
# gnome-keyring
|
||||
|
||||
The secret service as a module (novox/hq ADR 0208, ADR 0102).
|
||||
|
||||
- Installs `gnome-keyring` (it brings `gcr-4`, whose ssh agent this uses), `libsecret` (the client
|
||||
library and `secret-tool`) and `seahorse` (the keyrings' manager, for the operator).
|
||||
- Claims the mesh's `node-secret-service` seat (no verbs yet, ADR 0208 §2). It requires no display:
|
||||
the secret service is a D-Bus service, and a Wayland session uses the same module.
|
||||
- **Writes the PAM lines into the stacks, never over them** (ADR 0102). Each is a marked block at the
|
||||
end of the file; every other line stays the distribution's.
|
||||
- `/etc/pam.d/login`: `auth optional pam_gnome_keyring.so` and
|
||||
`session optional pam_gnome_keyring.so auto_start`. The login manager's stack includes `login`,
|
||||
so the password typed at the login screen unlocks the login keyring, and the login session starts
|
||||
the daemon.
|
||||
- `/etc/pam.d/passwd`: `password optional pam_gnome_keyring.so`, so changing the account's password
|
||||
changes the keyring's, and the next login still unlocks it.
|
||||
- **Starts no daemon.** PAM starts it at login, and D-Bus would if PAM had not.
|
||||
- Names gcr's ssh agent socket for the session, in the `xinitrc` slot `first`: `SSH_AUTH_SOCK` is
|
||||
`$XDG_RUNTIME_DIR/gcr/ssh`. The agent itself is `gcr-ssh-agent.socket`, a user unit the package
|
||||
enables by preset.
|
||||
|
||||
## Tools
|
||||
|
||||
None reads a secret. They ask the Secret Service for names, counts and lock states, and the agent for
|
||||
fingerprints.
|
||||
|
||||
| tool | does |
|
||||
|---|---|
|
||||
| `gnome_keyring_unlocked` | the login and default keyrings, locked or not; whether the daemon runs |
|
||||
| `gnome_keyring_lock` | lock a keyring now (login by default); unlocking stays the operator's |
|
||||
| `gnome_keyring_collections` | every keyring: id, label, locked, item count, created, changed, default |
|
||||
| `gnome_keyring_ssh_keys` | the agent's keys by fingerprint, size, type and comment |
|
||||
|
||||
## What it improves on what was found
|
||||
|
||||
- **The desktop's login unlocks the keyring.** Its `/etc/pam.d/login` had no keyring lines, so the
|
||||
keyring stayed locked after every login, and a script prompted for the password to unlock it. Both
|
||||
workstations now get the same lines.
|
||||
- **One daemon.** The session's start ran `gnome-keyring-daemon --start` a second time, asking for an
|
||||
ssh component that gnome-keyring no longer has, and the window manager ran an unlock-prompt script.
|
||||
Both go.
|
||||
- **The session has an ssh agent.** The found `export SSH_AUTH_SOCK` exported nothing: the second
|
||||
daemon printed no socket. The session's processes had no agent, although gcr's was listening.
|
||||
|
||||
## The default keyring is not the login keyring
|
||||
|
||||
On both workstations, measured on 2026-10-04, the default keyring, where programs store new secrets,
|
||||
is a second keyring, `Default_keyring`. The login keyring holds one item at most. PAM unlocks only the
|
||||
login keyring. Another keyring opens with it only if its password is stored in the login keyring
|
||||
("unlock automatically"). On the desktop the login keyring was locked and the default one unlocked,
|
||||
which the unlock-prompt script did.
|
||||
|
||||
After the first login with this module: `gnome_keyring_unlocked` shows both. If the default keyring
|
||||
is still locked, choose one, once, in `seahorse`:
|
||||
|
||||
- tick its *unlock automatically* when prompted;
|
||||
- or move its items into the login keyring and make that the default.
|
||||
|
||||
Which keyring is the default is the operator's data, never the module's.
|
||||
|
||||
## Blockers and a proposal
|
||||
|
||||
**`SSH_AUTH_SOCK` belongs in the account's environment, and ADR 0203 cannot say it yet.** The value
|
||||
is a path under the account's runtime directory (`/run/user/<uid>`). ADR 0203 forbids `$` in a
|
||||
contributed value, and no `${machine:…}` fact names that directory. So today the variable reaches
|
||||
only the X session and what it starts, through the `xinitrc` slot. An ssh login, the login shell's
|
||||
`execute` and the user manager's services do not get it.
|
||||
|
||||
**Proposed:** a machine fact `${machine:account-runtime-dir}`, resolved like `${machine:account-home}`
|
||||
from the account's uid. The variable then becomes an environment contribution:
|
||||
|
||||
> `environment.variables.SSH_AUTH_SOCK` = `${machine:account-runtime-dir}/gcr/ssh`
|
||||
|
||||
The slot contribution then goes. That is a progressive insight on ADR 0203, or a small record of its
|
||||
own. It changes the controller's machine facts, not this module's shape.
|
||||
|
||||
**User-scoped units (mesh-host #72).** `gcr-ssh-agent.socket` and `gnome-keyring-daemon.socket` are
|
||||
enabled by the package's presets on both workstations, and nothing in the mesh asserts it. Once user
|
||||
units ship, this module should declare both enabled.
|
||||
|
||||
## What it leaves as found
|
||||
|
||||
- The keyrings themselves (`~/.local/share/keyrings/`): the operator's data, never touched.
|
||||
- `~/.config/i3/unlock-keyring.sh`, the unlock-prompt script.
|
||||
|
||||
## Migration (ADR 0182)
|
||||
|
||||
1. **On the laptop,** `/etc/pam.d/login` already has the two lines outside any block. After the first
|
||||
push they are there twice. Delete the two hand-written ones, outside the `# BEGIN mesh` block.
|
||||
2. Once the `xorg` module writes the session's start, delete from your own part of `~/.xinitrc` the
|
||||
`eval $(/usr/bin/gnome-keyring-daemon --start …)` line and the `export SSH_AUTH_SOCK` after it.
|
||||
3. Once the `i3` module carries the main configuration, its `exec … unlock-keyring.sh` line is gone.
|
||||
Delete `~/.config/i3/unlock-keyring.sh`.
|
||||
4. **On the desktop,** log in again after the first push. The keyring is unlocked by the login from
|
||||
then on.
|
||||
|
||||
## Blockers
|
||||
|
||||
- `node-secret-service` and the `xinitrc` slot are ADR 0208's. Until the controller knows them,
|
||||
`mctl` reads them as unknown.
|
||||
- The environment fact above, and user-scoped units (mesh-host #72).
|
||||
@@ -0,0 +1,97 @@
|
||||
// Reading a tool's arguments: JSON numbers arrive as float64, and a missing argument is its default.
|
||||
// The same in every desktop module that carries it.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// text is a string argument, trimmed; required says an empty one is refused.
|
||||
func text(args map[string]any, key string, required bool) (string, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
if required {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s is a string, not %T", key, v)
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" && required {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// whole is a whole-number argument within [least, most], or def when absent.
|
||||
func whole(args map[string]any, key string, def, least, most int) (int, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
f, ok := v.(float64)
|
||||
if !ok {
|
||||
if i, isInt := v.(int); isInt {
|
||||
f = float64(i)
|
||||
} else {
|
||||
return 0, fmt.Errorf("%s is a number, not %T", key, v)
|
||||
}
|
||||
}
|
||||
if f != math.Trunc(f) {
|
||||
return 0, fmt.Errorf("%s is a whole number, not %v", key, f)
|
||||
}
|
||||
n := int(f)
|
||||
if n < least || n > most {
|
||||
return 0, fmt.Errorf("%s is %d; it is between %d and %d", key, n, least, most)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// flag is a boolean argument, or def when absent.
|
||||
func flag(args map[string]any, key string, def bool) (bool, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
b, ok := v.(bool)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("%s is true or false, not %T", key, v)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// texts is a list-of-strings argument.
|
||||
func texts(args map[string]any, key string) ([]string, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
list, ok := v.([]any)
|
||||
if !ok {
|
||||
if ss, isStrings := v.([]string); isStrings {
|
||||
return ss, nil
|
||||
}
|
||||
return nil, fmt.Errorf("%s is a list of strings, not %T", key, v)
|
||||
}
|
||||
out := make([]string, 0, len(list))
|
||||
for i, item := range list {
|
||||
s, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s[%d] is a string, not %T", key, i, item)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// seconds is a timeout argument in seconds, defaulted and bounded below the runtime's call limit.
|
||||
func seconds(args map[string]any, key string, def, most int) (time.Duration, error) {
|
||||
n, err := whole(args, key, def, 1, most)
|
||||
return time.Duration(n) * time.Second, err
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const nobody = 4194400
|
||||
|
||||
// secretService fakes busctl answering as gnome-keyring did on 2026-10-04: a session, a login and a
|
||||
// default keyring, the login one unlocked; Lock locks it.
|
||||
func secretService(t *testing.T) string {
|
||||
t.Helper()
|
||||
fakeMachine(t)
|
||||
runtime := filepath.Join(runUserDir, strconv.Itoa(os.Getuid()))
|
||||
if err := os.MkdirAll(runtime, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(runtime, "bus"), nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin := fakeBinaries(t, map[string]string{"busctl": `echo "$*" >> "$LOG"
|
||||
locked=false; [ -f "$LOG.locked" ] && locked=true
|
||||
case "$*" in
|
||||
*"get-property org.freedesktop.secrets /org/freedesktop/secrets org.freedesktop.Secret.Service Collections")
|
||||
echo '{"type":"ao","data":["/org/freedesktop/secrets/collection/session","/org/freedesktop/secrets/collection/login","/org/freedesktop/secrets/collection/Default_5fkeyring"]}' ;;
|
||||
*"ReadAlias s default") echo '{"type":"o","data":["/org/freedesktop/secrets/collection/Default_5fkeyring"]}' ;;
|
||||
*"/collection/login org.freedesktop.DBus.Properties GetAll"*)
|
||||
echo '{"type":"a{sv}","data":[{"Items":{"type":"ao","data":["/x/1","/x/2"]},"Label":{"type":"s","data":"Login"},"Locked":{"type":"b","data":'$locked'},"Created":{"type":"t","data":1752488320},"Modified":{"type":"t","data":0}}]}' ;;
|
||||
*"/collection/"*"GetAll"*)
|
||||
echo '{"type":"a{sv}","data":[{"Items":{"type":"ao","data":[]},"Label":{"type":"s","data":"Other"},"Locked":{"type":"b","data":true},"Created":{"type":"t","data":0},"Modified":{"type":"t","data":0}}]}' ;;
|
||||
*"Lock ao 1 /org/freedesktop/secrets/collection/login") touch "$LOG.locked"; echo '{"type":"aoo","data":[["/org/freedesktop/secrets/collection/login"],"/"]}' ;;
|
||||
*) echo "no such call: $*" >&2; exit 1 ;;
|
||||
esac`})
|
||||
t.Setenv("LOG", filepath.Join(bin, "log"))
|
||||
return bin
|
||||
}
|
||||
|
||||
func TestCollectionsAreNamesCountsAndLocksNeverItems(t *testing.T) {
|
||||
bin := secretService(t)
|
||||
got, err := Collections()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Collections) != 3 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
var login, def Collection
|
||||
for _, c := range got.Collections {
|
||||
switch c.ID {
|
||||
case "login":
|
||||
login = c
|
||||
case "Default_5fkeyring":
|
||||
def = c
|
||||
}
|
||||
}
|
||||
if login.Label != "Login" || login.Locked || login.Items != 2 || login.Created == "" || login.Modified != "" || login.Default {
|
||||
t.Fatalf("login: %+v", login)
|
||||
}
|
||||
if !def.Default || !def.Locked {
|
||||
t.Fatalf("default: %+v", def)
|
||||
}
|
||||
asked, _ := os.ReadFile(filepath.Join(bin, "log"))
|
||||
if strings.Contains(string(asked), "GetSecret") || strings.Contains(string(asked), "Item") && strings.Contains(string(asked), "Secret.Item") {
|
||||
t.Fatalf("a secret was asked for:\n%s", asked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnlockedAnswersTheLoginAndTheDefaultKeyring(t *testing.T) {
|
||||
secretService(t)
|
||||
fakeProcess(t, nobody, "gnome-keyring-d")
|
||||
got, err := Unlocked()
|
||||
if err != nil || !got.Daemon || got.Login == nil || got.Login.Locked || got.Default == nil || !got.Default.Default {
|
||||
t.Fatalf("%+v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockLocksTheLoginKeyringAndRefusesAPathForAnId(t *testing.T) {
|
||||
secretService(t)
|
||||
got, err := Lock("")
|
||||
if err != nil || got.Collection != "login" || !got.Locked {
|
||||
t.Fatalf("%+v, %v", got, err)
|
||||
}
|
||||
for _, bad := range []string{"../service", "login /org/x", "a b"} {
|
||||
if _, err := Lock(bad); err == nil {
|
||||
t.Errorf("%q was accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheAgentsKeysAreFingerprints(t *testing.T) {
|
||||
keys := parseKeys("256 SHA256:x+LmFabc op@laptop (ED25519)\n3072 SHA256:yyy a comment with spaces (RSA)\nThe agent has no identities.\n")
|
||||
if len(keys) != 2 || keys[0] != (Key{Bits: 256, Fingerprint: "SHA256:x+LmFabc", Comment: "op@laptop", Type: "ED25519"}) ||
|
||||
keys[1].Comment != "a comment with spaces" || keys[1].Type != "RSA" {
|
||||
t.Fatalf("%+v", keys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHKeysAsksGcrsAgentAndSaysWhenItDoesNotAnswer(t *testing.T) {
|
||||
secretService(t)
|
||||
bin := fakeBinaries(t, map[string]string{"ssh-add": `echo "$SSH_AUTH_SOCK $*" > "$LOG.ssh"; [ -f "$NOAGENT" ] && { echo "Could not open a connection" >&2; exit 2; }; echo "256 SHA256:abc op (ED25519)"`})
|
||||
t.Setenv("NOAGENT", filepath.Join(bin, "noagent"))
|
||||
got, err := SSHKeys()
|
||||
if err != nil || len(got.Keys) != 1 || !strings.HasSuffix(got.Agent, "/gcr/ssh") {
|
||||
t.Fatalf("%+v, %v", got, err)
|
||||
}
|
||||
asked, _ := os.ReadFile(os.Getenv("LOG") + ".ssh")
|
||||
if !strings.HasSuffix(strings.TrimSpace(string(asked)), "/gcr/ssh -l -E sha256") {
|
||||
t.Fatalf("asked: %s", asked)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(bin, "noagent"), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := SSHKeys(); err == nil || !strings.Contains(err.Error(), "gcr-ssh-agent.socket") {
|
||||
t.Fatalf("no agent: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithoutABusTheToolsSaySo(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
if _, err := Collections(); !errors.Is(err, ErrNoBus) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := SSHKeys(); !errors.Is(err, ErrNoBus) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// gnome-keyring's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the secret service's
|
||||
// tools, served by the node's runtime as the operator account. node-secret-service has no verbs yet
|
||||
// (ADR 0208 §2), so every tool here is the module's own. None of them ever reads a secret: they ask the
|
||||
// Secret Service for names, counts and lock states, and the ssh agent for fingerprints.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", tools()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func tools() []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{
|
||||
Name: "gnome_keyring_unlocked",
|
||||
Description: "Is the operator's keyring unlocked: the login keyring and the default one, each locked " +
|
||||
"or not, and whether the keyring daemon runs.",
|
||||
Run: func(map[string]any) (any, error) { return Unlocked() },
|
||||
},
|
||||
{
|
||||
Name: "gnome_keyring_lock",
|
||||
Description: "Lock a keyring now (the login keyring unless another is named): programs must ask " +
|
||||
"for its password again. Unlocking is the operator's, at their desktop.",
|
||||
Input: map[string]any{
|
||||
"collection": map[string]any{"type": "string", "description": "the keyring's id, as gnome_keyring_collections answers it (default login)"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
c, err := text(args, "collection", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Lock(c)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "gnome_keyring_collections",
|
||||
Description: "The operator's keyrings: each one's id, label, whether it is locked, how many items it " +
|
||||
"holds, when it was created and changed, and which is the default. Never an item, never a secret.",
|
||||
Run: func(map[string]any) (any, error) { return Collections() },
|
||||
},
|
||||
{
|
||||
Name: "gnome_keyring_ssh_keys",
|
||||
Description: "The keys the session's ssh agent (gcr's) holds, by fingerprint, size, type and comment. " +
|
||||
"Never a key.",
|
||||
Run: func(map[string]any) (any, error) { return SSHKeys() },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The module's manifest, read the way the catalogue reads it, for the manifest tests. The same in
|
||||
// every desktop module that carries it.
|
||||
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
Version string `json:"version"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Requires []string `json:"requires"`
|
||||
Claims []claim `json:"claims"`
|
||||
Seats []any `json:"seats"`
|
||||
Tools []string `json:"tools"`
|
||||
Environment *environment `json:"environment"`
|
||||
Shell []shellCode `json:"shell"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
type claim struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
Serves []string `json:"serves"`
|
||||
}
|
||||
|
||||
type environment struct {
|
||||
Variables map[string]string `json:"variables"`
|
||||
Path []map[string]any `json:"path"`
|
||||
}
|
||||
|
||||
type shellCode struct {
|
||||
For string `json:"for"`
|
||||
Slot string `json:"slot"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) manifest {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.DisallowUnknownFields()
|
||||
var m manifest
|
||||
if err := dec.Decode(&m); err != nil {
|
||||
t.Fatalf("module.json: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m manifest) resource(t *testing.T, id string) map[string]any {
|
||||
t.Helper()
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == id {
|
||||
return r
|
||||
}
|
||||
}
|
||||
t.Fatalf("no resource %q", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m manifest) packages() (present, absent []string) {
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "package" {
|
||||
if r["absent"] == true {
|
||||
absent = append(absent, r["package"].(string))
|
||||
} else {
|
||||
present = append(present, r["package"].(string))
|
||||
}
|
||||
}
|
||||
}
|
||||
return present, absent
|
||||
}
|
||||
|
||||
// sameAsSource checks that a file resource's content is byte for byte the module's source file, so
|
||||
// the readable file in the repository is what the machine gets.
|
||||
func (m manifest) sameAsSource(t *testing.T, id, source string) {
|
||||
t.Helper()
|
||||
want, err := os.ReadFile(filepath.Join("..", "..", source))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := m.resource(t, id)
|
||||
if r["type"] != "file" {
|
||||
t.Fatalf("%s is a %v, not a file", id, r["type"])
|
||||
}
|
||||
if got, _ := r["content"].(string); got != string(want) {
|
||||
t.Fatalf("resource %s's content is not %s: edit the source and copy it into module.json", id, source)
|
||||
}
|
||||
if r["owner"] != "${machine:account}" && !strings.HasPrefix(r["path"].(string), "/etc/") {
|
||||
t.Fatalf("%s under the home is the account's", id)
|
||||
}
|
||||
}
|
||||
|
||||
// checkTheToolsAgree checks that the manifest lists the module's own tools exactly, that the bundle
|
||||
// serves each seat verb the claims promise as <seat>.<verb>, and that the Go bundle is declared.
|
||||
func checkTheToolsAgree(t *testing.T, m manifest) {
|
||||
t.Helper()
|
||||
own, seat := map[string]bool{}, map[string]bool{}
|
||||
for _, tool := range tools() {
|
||||
if strings.Contains(tool.Name, ".") {
|
||||
seat[tool.Name] = true
|
||||
} else {
|
||||
own[tool.Name] = true
|
||||
}
|
||||
if strings.TrimSpace(tool.Description) == "" {
|
||||
t.Errorf("%s has no description", tool.Name)
|
||||
}
|
||||
}
|
||||
listed := map[string]bool{}
|
||||
for _, name := range m.Tools {
|
||||
listed[name] = true
|
||||
if !own[name] {
|
||||
t.Errorf("module.json lists %s, which the bundle does not serve", name)
|
||||
}
|
||||
}
|
||||
for name := range own {
|
||||
if !listed[name] {
|
||||
t.Errorf("the bundle serves %s, which module.json does not list", name)
|
||||
}
|
||||
if !strings.HasPrefix(name, strings.ReplaceAll(m.Module, "-", "_")+"_") {
|
||||
t.Errorf("%s is not prefixed with the module's name", name)
|
||||
}
|
||||
}
|
||||
promised := map[string]bool{}
|
||||
for _, c := range m.Claims {
|
||||
for _, verb := range c.Serves {
|
||||
promised[c.Name+"."+verb] = true
|
||||
if !seat[c.Name+"."+verb] {
|
||||
t.Errorf("the claim on %s promises %s, which the bundle does not serve", c.Name, verb)
|
||||
}
|
||||
}
|
||||
}
|
||||
for name := range seat {
|
||||
if !promised[name] {
|
||||
t.Errorf("the bundle serves %s, which no claim promises", name)
|
||||
}
|
||||
}
|
||||
var bundle map[string]any
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a["kind"] == "bundle" {
|
||||
bundle = a
|
||||
}
|
||||
}
|
||||
if bundle == nil || bundle["language"] != "go" || bundle["system"] != "arch" ||
|
||||
bundle["from"] != "cmd/"+m.Module+"-tools" || bundle["binary"] != m.Module+"-tools" {
|
||||
t.Errorf("the Go tools bundle: %v", bundle)
|
||||
}
|
||||
}
|
||||
|
||||
// checkNoSecretsOrInstallationNames refuses what a catalogue manifest must never carry.
|
||||
func checkNoSecretsOrInstallationNames(t *testing.T) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := strings.ToLower(string(raw))
|
||||
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "api_key", ".hal/", "greenclip daemon"} {
|
||||
if strings.Contains(s, never) {
|
||||
t.Errorf("module.json names %q", never)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// gnome-keyring's shape (novox/hq ADR 0208, ADR 0102): it claims node-secret-service, writes its PAM
|
||||
// lines into the login and passwd stacks as marked blocks (never over the files), and starts no daemon
|
||||
// of its own: PAM and D-Bus do.
|
||||
|
||||
func TestItClaimsTheSecretServiceSeat(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
if m.Module != "gnome-keyring" || m.Seats != nil || m.Requires != nil {
|
||||
t.Fatalf("module %q, seats %v, requires %v", m.Module, m.Seats, m.Requires)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Claims, []claim{{Name: "node-secret-service", Scope: "node"}}) {
|
||||
t.Fatalf("claims: %+v", m.Claims)
|
||||
}
|
||||
if present, absent := m.packages(); !reflect.DeepEqual(present, []string{"gnome-keyring", "libsecret", "seahorse"}) || absent != nil {
|
||||
t.Fatalf("packages: %v, absent %v", present, absent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThePAMLinesAreBlocksWrittenIntoTheStacks(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
for id, want := range map[string]struct{ path, source string }{
|
||||
"pam-login": {"/etc/pam.d/login", "files/pam/login"},
|
||||
"pam-passwd": {"/etc/pam.d/passwd", "files/pam/passwd"},
|
||||
} {
|
||||
m.sameAsSource(t, id, want.source)
|
||||
r := m.resource(t, id)
|
||||
if r["path"] != want.path || r["into"] != "block" || r["at"] != "end" || r["owner"] != nil {
|
||||
t.Errorf("%s: %v", id, r)
|
||||
}
|
||||
}
|
||||
login := m.resource(t, "pam-login")["content"].(string)
|
||||
if !strings.Contains(login, "\nauth optional pam_gnome_keyring.so\n") ||
|
||||
!strings.Contains(login, "\nsession optional pam_gnome_keyring.so auto_start\n") {
|
||||
t.Fatalf("%s", login)
|
||||
}
|
||||
}
|
||||
|
||||
func TestItStartsNoDaemonAndOnlyNamesTheAgentsSocket(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
if len(m.Shell) != 1 || m.Shell[0].For != "xinitrc" || m.Shell[0].Slot != "first" {
|
||||
t.Fatalf("%+v", m.Shell)
|
||||
}
|
||||
code := m.Shell[0].Code
|
||||
if strings.Contains(code, "gnome-keyring-daemon") || strings.Contains(code, "--unlock") ||
|
||||
!strings.Contains(code, `SSH_AUTH_SOCK="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/gcr/ssh"`) {
|
||||
t.Fatalf("%q", code)
|
||||
}
|
||||
if m.Environment != nil {
|
||||
t.Fatal("SSH_AUTH_SOCK needs the runtime directory, which ADR 0203 forbids in a value; it is not an environment contribution yet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
checkTheToolsAgree(t, m)
|
||||
checkNoSecretsOrInstallationNames(t)
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
// The operator's graphical session, as a tool the node's runtime runs finds it (novox/hq ADR 0208).
|
||||
//
|
||||
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
|
||||
// uid and none of the session's environment — no DISPLAY, no XAUTHORITY, no session bus. A tool that
|
||||
// draws on the screen or talks to the desktop's D-Bus must find them. It reads them from a process of
|
||||
// the account that is part of the session (the window manager first), the same thing `loginctl` and
|
||||
// a person's own shell would point at, and says where it found them.
|
||||
//
|
||||
// Long-lived programs a tool starts go to the account's own service manager through `systemd-run
|
||||
// --user`, never as children of the tool: the runtime's unit is a cgroup the service manager empties
|
||||
// whenever the runtime restarts, and a compositor or a clipboard owner started from inside it would
|
||||
// die with it.
|
||||
//
|
||||
// This file is the same in every desktop module that carries it; it moves into the Go SDK once a
|
||||
// second consumer outside the desktop wants it.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Where the session is looked for. Variables so a test can point them at a fake tree.
|
||||
var (
|
||||
procRoot = "/proc"
|
||||
runUserDir = "/run/user"
|
||||
x11Sockets = "/tmp/.X11-unix"
|
||||
)
|
||||
|
||||
// sessionHolders are the processes whose environment is the session's, best first: the window
|
||||
// manager is the session, the rest are its children. Anything else carrying DISPLAY ranks after them.
|
||||
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "xss-lock", "dunst", "clipmenud", "xterm"}
|
||||
|
||||
// sessionKeys are the variables a session carries that a tool hands on to what it runs.
|
||||
var sessionKeys = []string{"DISPLAY", "XAUTHORITY", "WAYLAND_DISPLAY", "DBUS_SESSION_BUS_ADDRESS",
|
||||
"XDG_RUNTIME_DIR", "XDG_SESSION_ID", "I3SOCK"}
|
||||
|
||||
// Session is what a tool needs to reach the operator's desktop.
|
||||
type Session struct {
|
||||
UID int `json:"uid"`
|
||||
Display string `json:"display,omitempty"`
|
||||
XAuthority string `json:"xauthority,omitempty"`
|
||||
Wayland string `json:"wayland_display,omitempty"`
|
||||
Bus string `json:"bus,omitempty"`
|
||||
RuntimeDir string `json:"runtime_dir,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
I3Sock string `json:"i3sock,omitempty"`
|
||||
// From says where the values were found: the tool's own environment, a process, or the socket.
|
||||
From string `json:"from"`
|
||||
}
|
||||
|
||||
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
|
||||
var ErrNoSession = errors.New("no graphical session")
|
||||
|
||||
// ErrTimedOut is what run answers for a command ended because it ran past its time.
|
||||
var ErrTimedOut = errors.New("timed out")
|
||||
|
||||
// ErrNoBus is answered by a tool that needs the session bus when the account has none.
|
||||
var ErrNoBus = errors.New("no session bus")
|
||||
|
||||
// operatorHome is the account's home: what the runtime was told, else the process's own.
|
||||
func operatorHome() string {
|
||||
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
|
||||
return h
|
||||
}
|
||||
h, _ := os.UserHomeDir()
|
||||
return h
|
||||
}
|
||||
|
||||
// findSession finds the graphical session of the account this tool runs as, or answers
|
||||
// ErrNoSession with what it looked at.
|
||||
func findSession() (Session, error) {
|
||||
s := findEnvironment()
|
||||
if s.Display == "" && s.Wayland == "" {
|
||||
return s, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY "+
|
||||
"or WAYLAND_DISPLAY, and no X server socket in %s has an authority file to go with it. "+
|
||||
"Is anyone logged in to the desktop?", ErrNoSession, s.UID, x11Sockets)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// findBus finds the account's session bus, which a logged-in account has whether or not a desktop
|
||||
// is running.
|
||||
func findBus() (Session, error) {
|
||||
s := findEnvironment()
|
||||
if s.Bus == "" {
|
||||
return s, fmt.Errorf("%w for uid %d: DBUS_SESSION_BUS_ADDRESS is not set and %s does not exist "+
|
||||
"(the account is not logged in)", ErrNoBus, s.UID, filepath.Join(runUserDir, strconv.Itoa(s.UID), "bus"))
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func findEnvironment() Session {
|
||||
uid := os.Getuid()
|
||||
s := Session{UID: uid}
|
||||
own := map[string]string{}
|
||||
for _, k := range sessionKeys {
|
||||
own[k] = os.Getenv(k)
|
||||
}
|
||||
if own["DISPLAY"] != "" || own["WAYLAND_DISPLAY"] != "" {
|
||||
s.fill(own)
|
||||
s.From = "the tool's own environment"
|
||||
} else if pid, comm, env, ok := sessionProcess(uid); ok {
|
||||
s.fill(env)
|
||||
s.From = fmt.Sprintf("process %s (pid %d)", comm, pid)
|
||||
} else if display, ok := lonelyX11Socket(); ok {
|
||||
if a := filepath.Join(operatorHome(), ".Xauthority"); exists(a) {
|
||||
s.Display, s.XAuthority = display, a
|
||||
s.From = "the X server socket and the account's ~/.Xauthority"
|
||||
}
|
||||
s.fill(own)
|
||||
} else {
|
||||
s.fill(own)
|
||||
s.From = "nothing: no session found"
|
||||
}
|
||||
// The bus and the runtime directory are the account's, whether or not the process named them.
|
||||
runtime := filepath.Join(runUserDir, strconv.Itoa(uid))
|
||||
if s.RuntimeDir == "" && exists(runtime) {
|
||||
s.RuntimeDir = runtime
|
||||
}
|
||||
if s.Bus == "" && s.RuntimeDir != "" && exists(filepath.Join(s.RuntimeDir, "bus")) {
|
||||
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Session) fill(env map[string]string) {
|
||||
set := func(dst *string, key string) {
|
||||
if *dst == "" {
|
||||
*dst = env[key]
|
||||
}
|
||||
}
|
||||
set(&s.Display, "DISPLAY")
|
||||
set(&s.XAuthority, "XAUTHORITY")
|
||||
set(&s.Wayland, "WAYLAND_DISPLAY")
|
||||
set(&s.Bus, "DBUS_SESSION_BUS_ADDRESS")
|
||||
set(&s.RuntimeDir, "XDG_RUNTIME_DIR")
|
||||
set(&s.SessionID, "XDG_SESSION_ID")
|
||||
set(&s.I3Sock, "I3SOCK")
|
||||
}
|
||||
|
||||
// sessionProcess is the best process of this uid whose environment names a display.
|
||||
func sessionProcess(uid int) (int, string, map[string]string, bool) {
|
||||
entries, err := os.ReadDir(procRoot)
|
||||
if err != nil {
|
||||
return 0, "", nil, false
|
||||
}
|
||||
type candidate struct {
|
||||
pid int
|
||||
comm string
|
||||
env map[string]string
|
||||
rank int
|
||||
}
|
||||
var found []candidate
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(procRoot, e.Name())
|
||||
if owner, ok := ownerOf(dir); !ok || owner != uid {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
env := parseEnviron(raw)
|
||||
if env["DISPLAY"] == "" && env["WAYLAND_DISPLAY"] == "" {
|
||||
continue
|
||||
}
|
||||
comm := readTrimmed(filepath.Join(dir, "comm"))
|
||||
rank := len(sessionHolders)
|
||||
for i, h := range sessionHolders {
|
||||
if h == comm {
|
||||
rank = i
|
||||
break
|
||||
}
|
||||
}
|
||||
found = append(found, candidate{pid, comm, env, rank})
|
||||
}
|
||||
if len(found) == 0 {
|
||||
return 0, "", nil, false
|
||||
}
|
||||
sort.Slice(found, func(i, j int) bool {
|
||||
if found[i].rank != found[j].rank {
|
||||
return found[i].rank < found[j].rank
|
||||
}
|
||||
return found[i].pid > found[j].pid // the newer of two equals
|
||||
})
|
||||
best := found[0]
|
||||
return best.pid, best.comm, best.env, true
|
||||
}
|
||||
|
||||
func parseEnviron(raw []byte) map[string]string {
|
||||
env := map[string]string{}
|
||||
for _, kv := range bytes.Split(raw, []byte{0}) {
|
||||
if i := bytes.IndexByte(kv, '='); i > 0 {
|
||||
env[string(kv[:i])] = string(kv[i+1:])
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func ownerOf(path string) (int, bool) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
st, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return int(st.Uid), true
|
||||
}
|
||||
|
||||
// lonelyX11Socket is the display of the one X server socket there is, when there is exactly one.
|
||||
func lonelyX11Socket() (string, bool) {
|
||||
entries, err := os.ReadDir(x11Sockets)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
var displays []string
|
||||
for _, e := range entries {
|
||||
if n := strings.TrimPrefix(e.Name(), "X"); n != e.Name() {
|
||||
if _, err := strconv.Atoi(n); err == nil {
|
||||
displays = append(displays, ":"+n)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(displays) != 1 {
|
||||
return "", false
|
||||
}
|
||||
return displays[0], true
|
||||
}
|
||||
|
||||
func readTrimmed(path string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
func exists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Env is this process's environment with the session's variables in place of its own.
|
||||
func (s Session) Env() []string {
|
||||
drop := map[string]bool{}
|
||||
for _, k := range sessionKeys {
|
||||
drop[k] = true
|
||||
}
|
||||
var env []string
|
||||
for _, kv := range os.Environ() {
|
||||
if i := strings.IndexByte(kv, '='); i > 0 && drop[kv[:i]] {
|
||||
continue
|
||||
}
|
||||
env = append(env, kv)
|
||||
}
|
||||
add := func(k, v string) {
|
||||
if v != "" {
|
||||
env = append(env, k+"="+v)
|
||||
}
|
||||
}
|
||||
add("DISPLAY", s.Display)
|
||||
add("XAUTHORITY", s.XAuthority)
|
||||
add("WAYLAND_DISPLAY", s.Wayland)
|
||||
add("DBUS_SESSION_BUS_ADDRESS", s.Bus)
|
||||
add("XDG_RUNTIME_DIR", s.RuntimeDir)
|
||||
add("XDG_SESSION_ID", s.SessionID)
|
||||
add("I3SOCK", s.I3Sock)
|
||||
return env
|
||||
}
|
||||
|
||||
// mostOutput bounds what a command may answer with, per stream.
|
||||
const mostOutput = 256 << 10
|
||||
|
||||
// Result is what a command did.
|
||||
type Result struct {
|
||||
Stdout string `json:"stdout"`
|
||||
Stderr string `json:"stderr,omitempty"`
|
||||
Code int `json:"code"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
}
|
||||
|
||||
// run runs a command in the session's environment, its input given, ended with everything it
|
||||
// started after timeout. A command that is not installed is an error naming it; one that exits
|
||||
// non-zero is a Result with its code, for the caller to judge.
|
||||
func (s Session) run(timeout time.Duration, stdin string, name string, args ...string) (Result, error) {
|
||||
path, err := exec.LookPath(name)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("%s is not installed on this machine", name)
|
||||
}
|
||||
cmd := exec.Command(path, args...)
|
||||
cmd.Env = s.Env()
|
||||
if home := operatorHome(); exists(home) {
|
||||
cmd.Dir = home
|
||||
}
|
||||
if stdin != "" {
|
||||
cmd.Stdin = strings.NewReader(stdin)
|
||||
}
|
||||
var out, errOut capped
|
||||
cmd.Stdout, cmd.Stderr = &out, &errOut
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return Result{}, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
select {
|
||||
case err = <-done:
|
||||
case <-time.After(timeout):
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
<-done
|
||||
return Result{Stdout: out.String(), Stderr: errOut.String()},
|
||||
fmt.Errorf("%s did not finish within %s and was ended: %w", name, timeout, ErrTimedOut)
|
||||
}
|
||||
r := Result{Stdout: out.String(), Stderr: errOut.String(), Truncated: out.cut || errOut.cut}
|
||||
var exit *exec.ExitError
|
||||
if errors.As(err, &exit) {
|
||||
r.Code = exit.ExitCode()
|
||||
} else if err != nil {
|
||||
return r, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// detach starts a long-lived program under the account's own service manager, as a transient unit
|
||||
// that carries the session's display, so it outlives the runtime that asked for it. A unit already
|
||||
// running under the same name is stopped first, so a fixed name means "at most one".
|
||||
func (s Session) detach(unit string, args ...string) error {
|
||||
if s.RuntimeDir == "" {
|
||||
return fmt.Errorf("%w: the account's runtime directory is missing, so its service manager "+
|
||||
"cannot be reached", ErrNoBus)
|
||||
}
|
||||
_, _ = s.run(5*time.Second, "", "systemctl", "--user", "stop", unit+".service")
|
||||
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
|
||||
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
|
||||
{"WAYLAND_DISPLAY", s.Wayland}, {"XDG_SESSION_ID", s.SessionID}, {"I3SOCK", s.I3Sock}} {
|
||||
if kv[1] != "" {
|
||||
call = append(call, "--setenv="+kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
call = append(call, "--")
|
||||
call = append(call, args...)
|
||||
r, err := s.run(10*time.Second, "", "systemd-run", call...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if r.Code != 0 {
|
||||
return fmt.Errorf("systemd-run %s: %s", unit, strings.TrimSpace(r.Stderr))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// uniqueUnit is a transient unit name that will not collide with an earlier one.
|
||||
func uniqueUnit(prefix string) string {
|
||||
return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano())
|
||||
}
|
||||
|
||||
type capped struct {
|
||||
bytes.Buffer
|
||||
cut bool
|
||||
}
|
||||
|
||||
func (c *capped) Write(p []byte) (int, error) {
|
||||
if room := mostOutput - c.Len(); room < len(p) {
|
||||
if room > 0 {
|
||||
c.Buffer.Write(p[:room])
|
||||
}
|
||||
c.cut = true
|
||||
return len(p), nil
|
||||
}
|
||||
return c.Buffer.Write(p)
|
||||
}
|
||||
|
||||
// processesOf are the pids of this uid's processes whose command name is comm, oldest first.
|
||||
func processesOf(comm string) []int {
|
||||
entries, err := os.ReadDir(procRoot)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
uid := os.Getuid()
|
||||
var pids []int
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(procRoot, e.Name())
|
||||
if owner, ok := ownerOf(dir); !ok || owner != uid {
|
||||
continue
|
||||
}
|
||||
if readTrimmed(filepath.Join(dir, "comm")) == comm {
|
||||
pids = append(pids, pid)
|
||||
}
|
||||
}
|
||||
sort.Ints(pids)
|
||||
return pids
|
||||
}
|
||||
|
||||
// signalAll sends sig to every process of this uid named comm, and answers the pids it reached.
|
||||
func signalAll(comm string, sig syscall.Signal) []int {
|
||||
var reached []int
|
||||
for _, pid := range processesOf(comm) {
|
||||
if syscall.Kill(pid, sig) == nil {
|
||||
reached = append(reached, pid)
|
||||
}
|
||||
}
|
||||
return reached
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeMachine points the session finder at a temporary /proc, /run/user and X socket directory, with
|
||||
// none of the test process's own session variables, and gives back the root.
|
||||
func fakeMachine(t *testing.T) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
procRoot, runUserDir, x11Sockets = filepath.Join(root, "proc"), filepath.Join(root, "run-user"), filepath.Join(root, "x11")
|
||||
for _, d := range []string{procRoot, runUserDir, x11Sockets} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, k := range sessionKeys {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
t.Setenv("MESH_OPERATOR_HOME", filepath.Join(root, "home"))
|
||||
t.Cleanup(func() { procRoot, runUserDir, x11Sockets = "/proc", "/run/user", "/tmp/.X11-unix" })
|
||||
return root
|
||||
}
|
||||
|
||||
func fakeProcess(t *testing.T, pid int, comm string, env ...string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(procRoot, strconv.Itoa(pid))
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "comm"), []byte(comm+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "environ"), []byte(strings.Join(env, "\x00")+"\x00"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSessionIsReadFromTheWindowManagerBeforeAnyOtherProcess(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
fakeProcess(t, 900, "xterm", "DISPLAY=:9", "XAUTHORITY=/elsewhere")
|
||||
fakeProcess(t, 100, "i3", "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority",
|
||||
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus", "XDG_SESSION_ID=3", "SECRET_TOKEN=never-copied")
|
||||
fakeProcess(t, 50, "bash", "PATH=/usr/bin")
|
||||
s, err := findSession()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Display != ":1" || s.XAuthority != "/home/op/.Xauthority" || s.SessionID != "3" || !strings.Contains(s.From, "i3 (pid 100)") {
|
||||
t.Fatalf("the window manager's environment: %+v", s)
|
||||
}
|
||||
for _, kv := range s.Env() {
|
||||
if strings.HasPrefix(kv, "SECRET_TOKEN=") {
|
||||
t.Fatal("a variable of the session process that is not a session variable was handed on")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyProcessCarryingADisplayServesWhenTheWindowManagerIsNotFound(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
fakeProcess(t, 10, "firefox", "DISPLAY=:0")
|
||||
fakeProcess(t, 20, "firefox", "DISPLAY=:2")
|
||||
s, err := findSession()
|
||||
if err != nil || s.Display != ":2" {
|
||||
t.Fatalf("the newest of two equals: %+v, %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoSessionIsAClearAnswerNotAGuess(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
fakeProcess(t, 10, "sshd", "PATH=/usr/bin")
|
||||
_, err := findSession()
|
||||
if !errors.Is(err, ErrNoSession) || !strings.Contains(err.Error(), "logged in to the desktop") {
|
||||
t.Fatalf("no session: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneXSocketAndTheAccountsAuthorityFileAreASession(t *testing.T) {
|
||||
root := fakeMachine(t)
|
||||
if err := os.WriteFile(filepath.Join(x11Sockets, "X0"), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "home"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "home", ".Xauthority"), nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := findSession()
|
||||
if err != nil || s.Display != ":0" || !strings.HasSuffix(s.XAuthority, "/home/.Xauthority") {
|
||||
t.Fatalf("socket and authority: %+v, %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheBusIsTheAccountsRuntimeDirectoryWhenNoProcessNamesIt(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
runtime := filepath.Join(runUserDir, strconv.Itoa(os.Getuid()))
|
||||
if _, err := findBus(); !errors.Is(err, ErrNoBus) {
|
||||
t.Fatalf("no runtime directory is no bus: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(runtime, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(runtime, "bus"), nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := findBus()
|
||||
if err != nil || s.Bus != "unix:path="+filepath.Join(runtime, "bus") || s.RuntimeDir != runtime {
|
||||
t.Fatalf("bus: %+v, %v", s, err)
|
||||
}
|
||||
env := strings.Join(s.Env(), "\n")
|
||||
if !strings.Contains(env, "XDG_RUNTIME_DIR="+runtime) || !strings.Contains(env, "DBUS_SESSION_BUS_ADDRESS=unix:path=") {
|
||||
t.Fatalf("the bus is handed on: %s", env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACommandIsBoundedAndANonZeroExitIsAResult(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
s := Session{}
|
||||
r, err := s.run(5*time.Second, "in", "sh", "-c", "cat; echo err >&2; exit 3")
|
||||
if err != nil || r.Stdout != "in" || r.Code != 3 || strings.TrimSpace(r.Stderr) != "err" {
|
||||
t.Fatalf("result: %+v, %v", r, err)
|
||||
}
|
||||
start := time.Now()
|
||||
if _, err := s.run(200*time.Millisecond, "", "sh", "-c", "sleep 30 & sleep 30"); err == nil || time.Since(start) > 5*time.Second {
|
||||
t.Fatalf("a command past its time is ended with what it started: %v after %s", err, time.Since(start))
|
||||
}
|
||||
if _, err := s.run(time.Second, "", "no-such-program-here"); err == nil || !strings.Contains(err.Error(), "not installed") {
|
||||
t.Fatalf("a missing program: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetachAsksTheAccountsServiceManagerWithTheSessionsDisplay(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
bin := fakeBinaries(t, map[string]string{
|
||||
"systemctl": `echo "systemctl $*" >> "$LOG"`,
|
||||
"systemd-run": `echo "systemd-run $*" >> "$LOG"`,
|
||||
})
|
||||
log := filepath.Join(bin, "log")
|
||||
t.Setenv("LOG", log)
|
||||
s := Session{Display: ":1", XAuthority: "/x", RuntimeDir: "/run/user/1"}
|
||||
if err := s.detach("picom-session", "picom", "--config", "/c"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := os.ReadFile(log)
|
||||
want := "systemctl --user stop picom-session.service\n" +
|
||||
"systemd-run --user --collect --quiet --unit=picom-session --setenv=DISPLAY=:1 --setenv=XAUTHORITY=/x -- picom --config /c\n"
|
||||
if string(got) != want {
|
||||
t.Fatalf("detach ran:\n%s\nwant:\n%s", got, want)
|
||||
}
|
||||
if err := (Session{}).detach("x", "y"); !errors.Is(err, ErrNoBus) {
|
||||
t.Fatalf("no runtime directory: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeBinaries puts shell scripts named for programs first on PATH, and answers their directory.
|
||||
func fakeBinaries(t *testing.T, scripts map[string]string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for name, body := range scripts {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
return dir
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# The login keyring (module gnome-keyring, novox/hq ADR 0208, ADR 0102): the password typed at the
|
||||
# login screen unlocks the keyring, and the login session starts the keyring daemon with it.
|
||||
auth optional pam_gnome_keyring.so
|
||||
session optional pam_gnome_keyring.so auto_start
|
||||
@@ -0,0 +1,3 @@
|
||||
# The login keyring (module gnome-keyring, novox/hq ADR 0208, ADR 0102): changing the account's
|
||||
# password changes the login keyring's with it, so the next login still unlocks it.
|
||||
password optional pam_gnome_keyring.so
|
||||
@@ -0,0 +1,5 @@
|
||||
module gnomekeyring
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"module": "gnome-keyring",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-secret-service",
|
||||
"scope": "node"
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
"gnome_keyring_unlocked",
|
||||
"gnome_keyring_lock",
|
||||
"gnome_keyring_collections",
|
||||
"gnome_keyring_ssh_keys"
|
||||
],
|
||||
"shell": [
|
||||
{
|
||||
"for": "xinitrc",
|
||||
"slot": "first",
|
||||
"code": "# The ssh agent (module gnome-keyring, novox/hq ADR 0208): gcr's, which the account's service manager\n# starts on first use from its socket. Named here, for the session and every terminal it starts, until\n# the account's environment can say a path under the runtime directory (see the module's README).\nSSH_AUTH_SOCK=\"${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/gcr/ssh\"\nexport SSH_AUTH_SOCK\n"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
"type": "package",
|
||||
"package": "gnome-keyring"
|
||||
},
|
||||
{
|
||||
"id": "library",
|
||||
"type": "package",
|
||||
"package": "libsecret"
|
||||
},
|
||||
{
|
||||
"id": "manager",
|
||||
"type": "package",
|
||||
"package": "seahorse"
|
||||
},
|
||||
{
|
||||
"id": "pam-login",
|
||||
"type": "file",
|
||||
"path": "/etc/pam.d/login",
|
||||
"mode": "0644",
|
||||
"into": "block",
|
||||
"at": "end",
|
||||
"content": "# The login keyring (module gnome-keyring, novox/hq ADR 0208, ADR 0102): the password typed at the\n# login screen unlocks the keyring, and the login session starts the keyring daemon with it.\nauth optional pam_gnome_keyring.so\nsession optional pam_gnome_keyring.so auto_start\n"
|
||||
},
|
||||
{
|
||||
"id": "pam-passwd",
|
||||
"type": "file",
|
||||
"path": "/etc/pam.d/passwd",
|
||||
"mode": "0644",
|
||||
"into": "block",
|
||||
"at": "end",
|
||||
"content": "# The login keyring (module gnome-keyring, novox/hq ADR 0208, ADR 0102): changing the account's\n# password changes the login keyring's with it, so the next login still unlocks it.\npassword optional pam_gnome_keyring.so\n"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/gnome-keyring-tools",
|
||||
"binary": "gnome-keyring-tools",
|
||||
"loads": [
|
||||
"gnome-keyring-tools"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user