返回 DeepSeek-Reasonix
credentials_keyring_unix.go
根目录 / internal / config / credentials_keyring_unix.go
1 //go:build (dragonfly && cgo) || (freebsd && cgo) || linux || netbsd || openbsd
2
3 package config
4
5 import (
6 "context"
7 "fmt"
8 "slices"
9 "strings"
10
11 dbus "github.com/godbus/dbus/v5"
12 )
13
14 // Secret Service constants (same service Reasonix historically used via
15 // zalando/go-keyring). Every D-Bus call uses the shared migration context so a
16 // stuck bus cannot hang CLI startup past the batch deadline.
17 const (
18 ssServiceName = "org.freedesktop.secrets"
19 ssServicePath = "/org/freedesktop/secrets"
20 ssServiceInterface = "org.freedesktop.Secret.Service"
21 ssCollectionInterface = "org.freedesktop.Secret.Collection"
22 ssItemInterface = "org.freedesktop.Secret.Item"
23 ssSessionInterface = "org.freedesktop.Secret.Session"
24 ssCollectionsIface = "org.freedesktop.Secret.Service"
25 ssCollectionsProp = "Collections"
26 ssLoginCollection = "/org/freedesktop/secrets/collection/login"
27 ssLoginAlias = "/org/freedesktop/secrets/aliases/default"
28 ssPropertiesInterface = "org.freedesktop.DBus.Properties"
29 )
30
31 type ssSecret struct {
32 Session dbus.ObjectPath
33 Parameters []byte
34 Value []byte
35 ContentType string `dbus:"content_type"`
36 }
37
38 // legacyKeyringProbe reads one legacy credential from Secret Service under ctx.
39 // It opens a private, caller-owned session-bus connection (never dbus.SessionBus),
40 // bounds Dial/Auth/Hello by ctx, routes every method/property call through
41 // CallWithContext(ctx), and always closes the connection before returning so
42 // godbus worker goroutines cannot leak into goleak-checked tests.
43 func legacyKeyringProbe(ctx context.Context, key string) legacyKeyringOutcome {
44 key = strings.TrimSpace(key)
45 if key == "" {
46 return legacyKeyringOutcome{Status: legacyKeyringAbsent}
47 }
48 if err := ctx.Err(); err != nil {
49 return legacyKeyringOutcome{Status: legacyKeyringTimeout}
50 }
51
52 conn, err := openPrivateSessionBus(ctx)
53 if err != nil {
54 return mapKeyringCtxErr(ctx, err)
55 }
56 defer func() { _ = conn.Close() }()
57
58 svc := conn.Object(ssServiceName, ssServicePath)
59 collectionPath, err := ssResolveLoginCollection(ctx, svc)
60 if err != nil {
61 return mapKeyringCtxErr(ctx, err)
62 }
63 if err := ssUnlock(ctx, svc, collectionPath); err != nil {
64 return mapKeyringCtxErr(ctx, err)
65 }
66
67 collection := conn.Object(ssServiceName, collectionPath)
68 search := map[string]string{
69 "username": key,
70 "service": credentialsKeyringService,
71 }
72 var results []dbus.ObjectPath
73 if err := collection.CallWithContext(ctx, ssCollectionInterface+".SearchItems", 0, search).Store(&results); err != nil {
74 return mapKeyringCtxErr(ctx, err)
75 }
76 if len(results) == 0 {
77 return legacyKeyringOutcome{Status: legacyKeyringAbsent}
78 }
79
80 var disregard dbus.Variant
81 var sessionPath dbus.ObjectPath
82 if err := svc.CallWithContext(ctx, ssServiceInterface+".OpenSession", 0, "plain", dbus.MakeVariant("")).Store(&disregard, &sessionPath); err != nil {
83 return mapKeyringCtxErr(ctx, err)
84 }
85 // Always close the Secret Service session with the remaining budget (never
86 // context.Background) so a stuck Close still respects the migration deadline.
87 defer func() {
88 session := conn.Object(ssServiceName, sessionPath)
89 _ = session.CallWithContext(ctx, ssSessionInterface+".Close", 0).Err
90 }()
91
92 if err := ssUnlock(ctx, svc, results[0]); err != nil {
93 return mapKeyringCtxErr(ctx, err)
94 }
95
96 var secret ssSecret
97 item := conn.Object(ssServiceName, results[0])
98 if err := item.CallWithContext(ctx, ssItemInterface+".GetSecret", 0, sessionPath).Store(&secret); err != nil {
99 return mapKeyringCtxErr(ctx, err)
100 }
101 if len(secret.Value) == 0 {
102 return legacyKeyringOutcome{Status: legacyKeyringAbsent}
103 }
104 return legacyKeyringOutcome{Status: legacyKeyringFound, Value: string(secret.Value)}
105 }
106
107 // openPrivateSessionBus dials a private session-bus connection (Auth+Hello)
108 // without using the process-global SessionBus cache.
109 //
110 // Connection lifecycle is bound to ctx via dbus.WithContext(ctx): when the
111 // migration budget expires, godbus closes the transport so Auth/Hello that have
112 // already obtained a *Conn unblock instead of hanging forever. We never call
113 // dbus-launch (NoAutoStartup): missing session address fails closed as error,
114 // which is correct for headless/CI and avoids an uncancellable CombinedOutput.
115 //
116 // Dial itself is still not fully context-cancellable in godbus before newConn
117 // installs WithContext; the outer select bounds the caller's wait, and any late
118 // *Conn is always closed.
119 func openPrivateSessionBus(ctx context.Context) (*dbus.Conn, error) {
120 if err := ctx.Err(); err != nil {
121 return nil, err
122 }
123 type result struct {
124 conn *dbus.Conn
125 err error
126 }
127 ch := make(chan result, 1)
128 go func() {
129 conn, err := connectPrivateSessionBus(ctx)
130 ch <- result{conn: conn, err: err}
131 }()
132 select {
133 case <-ctx.Done():
134 // Drain so the connect goroutine is reaped when WithContext causes
135 // Auth/Hello to return; always Close a late *Conn.
136 go func() {
137 r := <-ch
138 if r.conn != nil {
139 _ = r.conn.Close()
140 }
141 }()
142 return nil, ctx.Err()
143 case r := <-ch:
144 if r.err != nil {
145 if r.conn != nil {
146 _ = r.conn.Close()
147 }
148 if ctx.Err() != nil {
149 return nil, ctx.Err()
150 }
151 return nil, r.err
152 }
153 if err := ctx.Err(); err != nil {
154 _ = r.conn.Close()
155 return nil, err
156 }
157 return r.conn, nil
158 }
159 }
160
161 // connectPrivateSessionBus opens a private, context-bound session bus and
162 // completes Auth+Hello. Prefer NoAutoStartup so we never block in dbus-launch.
163 func connectPrivateSessionBus(ctx context.Context) (*dbus.Conn, error) {
164 // WithContext: parent cancel → conn.Close → unblocks Auth transport I/O and
165 // Hello Call waiters once the *Conn exists.
166 conn, err := dbus.SessionBusPrivateNoAutoStartup(dbus.WithContext(ctx))
167 if err != nil {
168 return nil, err
169 }
170 if err := conn.Auth(nil); err != nil {
171 _ = conn.Close()
172 return nil, err
173 }
174 if err := conn.Hello(); err != nil {
175 _ = conn.Close()
176 return nil, err
177 }
178 return conn, nil
179 }
180
181 func ssResolveLoginCollection(ctx context.Context, svc dbus.BusObject) (dbus.ObjectPath, error) {
182 path := dbus.ObjectPath(ssLoginCollection)
183 val, err := ssGetProperty(ctx, svc, ssCollectionsIface, ssCollectionsProp)
184 if err != nil {
185 // Fall back to the default alias when Collections is unavailable.
186 return dbus.ObjectPath(ssLoginAlias), nil
187 }
188 paths, _ := val.Value().([]dbus.ObjectPath)
189 if slices.Contains(paths, path) {
190 return path, nil
191 }
192 return dbus.ObjectPath(ssLoginAlias), nil
193 }
194
195 // ssGetProperty is CallWithContext-based Properties.Get. BusObject.GetProperty
196 // uses a non-context Call and would escape the migration deadline.
197 func ssGetProperty(ctx context.Context, obj dbus.BusObject, iface, name string) (dbus.Variant, error) {
198 var val dbus.Variant
199 err := obj.CallWithContext(ctx, ssPropertiesInterface+".Get", 0, iface, name).Store(&val)
200 if err != nil {
201 return dbus.Variant{}, err
202 }
203 return val, nil
204 }
205
206 func ssUnlock(ctx context.Context, svc dbus.BusObject, target dbus.ObjectPath) error {
207 var unlocked []dbus.ObjectPath
208 var prompt dbus.ObjectPath
209 if err := svc.CallWithContext(ctx, ssServiceInterface+".Unlock", 0, []dbus.ObjectPath{target}).Store(&unlocked, &prompt); err != nil {
210 return err
211 }
212 // Migration must not wait on an interactive prompt (would hang CLI startup).
213 if prompt != "/" && prompt != "" {
214 for _, p := range unlocked {
215 if p == target || target == dbus.ObjectPath(ssLoginAlias) {
216 return nil
217 }
218 }
219 return fmt.Errorf("secret service unlock requires interactive prompt")
220 }
221 return nil
222 }
223
224 func mapKeyringCtxErr(ctx context.Context, err error) legacyKeyringOutcome {
225 if err == nil {
226 return legacyKeyringOutcome{Status: legacyKeyringAbsent}
227 }
228 if ctx.Err() != nil {
229 return legacyKeyringOutcome{Status: legacyKeyringTimeout}
230 }
231 return legacyKeyringOutcome{Status: legacyKeyringError}
232 }
233
233 lines GO