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