Skip to content

Commit ac1ec0c

Browse files
committed
feat: Add ListUsers method to retrieve usernames for a service (#133)
Signed-off-by: Ayoub Abidi <mrayoubabidi@gmail.com>
1 parent 5c6f7e0 commit ac1ec0c

8 files changed

Lines changed: 299 additions & 0 deletions

File tree

keyring.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ type Keyring interface {
2727
Delete(service, user string) error
2828
// DeleteAll deletes all secrets for a given service
2929
DeleteAll(service string) error
30+
// ListUsers returns a list of all users for a given service
31+
ListUsers(service string) ([]string, error)
3032
}
3133

3234
// Set password in keyring for user.
@@ -48,3 +50,8 @@ func Delete(service, user string) error {
4850
func DeleteAll(service string) error {
4951
return provider.DeleteAll(service)
5052
}
53+
54+
// ListUsers returns a list of all users for a given service
55+
func ListUsers(service string) ([]string, error) {
56+
return provider.ListUsers(service)
57+
}

keyring_darwin.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,57 @@ func (k macOSXKeychain) DeleteAll(service string) error {
135135

136136
}
137137

138+
// ListUsers returns a list of all users for a given service
139+
func (k macOSXKeychain) ListUsers(service string) ([]string, error) {
140+
if service == "" {
141+
return []string{}, nil
142+
}
143+
144+
out, err := exec.Command(execPathKeychain, "dump-keyring").CombinedOutput()
145+
if err != nil {
146+
return nil, err
147+
}
148+
149+
var users []string
150+
seenUsers := make(map[string]bool)
151+
lines := strings.Split(string(out), "\n")
152+
153+
// Parse dump-keyring output looking for generic passwords matching our service
154+
// Format: keychain: "/Users/username/Library/Keychains/login.keychain-db"
155+
// class: "genp"
156+
// attributes:
157+
// "svce"<blob>="service-name"
158+
// "acct"<blob>="account-name"
159+
for i := 0; i < len(lines); i++ {
160+
line := strings.TrimSpace(lines[i])
161+
162+
// Look for service attribute matching our service
163+
if strings.Contains(line, `"svce"`) && strings.Contains(line, `="`+service+`"`) {
164+
// Found a matching service, now look for the account attribute
165+
// It should be nearby in the attributes section
166+
for j := i - 10; j < i+10 && j < len(lines) && j >= 0; j++ {
167+
acctLine := strings.TrimSpace(lines[j])
168+
if strings.Contains(acctLine, `"acct"`) {
169+
// Extract account name from: "acct"<blob>="username"
170+
if idx := strings.Index(acctLine, `="`); idx != -1 {
171+
start := idx + 2
172+
if end := strings.Index(acctLine[start:], `"`); end != -1 {
173+
username := acctLine[start : start+end]
174+
if !seenUsers[username] {
175+
seenUsers[username] = true
176+
users = append(users, username)
177+
}
178+
break
179+
}
180+
}
181+
}
182+
}
183+
}
184+
}
185+
186+
return users, nil
187+
}
188+
138189
func init() {
139190
provider = macOSXKeychain{}
140191
}

keyring_fallback.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,7 @@ func (fallbackServiceProvider) Delete(service, user string) error {
2525
func (fallbackServiceProvider) DeleteAll(service string) error {
2626
return ErrUnsupportedPlatform
2727
}
28+
29+
func (fallbackServiceProvider) ListUsers(service string) ([]string, error) {
30+
return nil, ErrUnsupportedPlatform
31+
}

keyring_mock.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,20 @@ func (m *mockProvider) DeleteAll(service string) error {
5959
return nil
6060
}
6161

62+
// ListUsers returns a list of all users for a given service
63+
func (m *mockProvider) ListUsers(service string) ([]string, error) {
64+
if m.mockError != nil {
65+
return nil, m.mockError
66+
}
67+
users := []string{}
68+
if m.mockStore != nil && m.mockStore[service] != nil {
69+
for user := range m.mockStore[service] {
70+
users = append(users, user)
71+
}
72+
}
73+
return users, nil
74+
}
75+
6276
// MockInit sets the provider to a mocked memory store
6377
func MockInit() {
6478
provider = &mockProvider{}

keyring_test.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,3 +181,137 @@ func TestDeleteAllEmptyService(t *testing.T) {
181181
t.Errorf("Should not have deleted secret from another service")
182182
}
183183
}
184+
185+
// TestListUsers tests listing all users for a service.
186+
func TestListUsers(t *testing.T) {
187+
// Set up multiple secrets for the same service
188+
const service2 = "test-service-list"
189+
err := Set(service2, "user1", "password1")
190+
if err != nil {
191+
t.Errorf("Should not fail, got: %s", err)
192+
}
193+
194+
err = Set(service2, "user2", "password2")
195+
if err != nil {
196+
t.Errorf("Should not fail, got: %s", err)
197+
}
198+
199+
err = Set(service2, "user3", "password3")
200+
if err != nil {
201+
t.Errorf("Should not fail, got: %s", err)
202+
}
203+
204+
// List all users for the service
205+
users, err := ListUsers(service2)
206+
if err != nil {
207+
t.Errorf("Should not fail, got: %s", err)
208+
}
209+
210+
// Verify we got all three users
211+
if len(users) != 3 {
212+
t.Errorf("Expected 3 users, got %d", len(users))
213+
}
214+
215+
// Verify the users are correct (order doesn't matter)
216+
expectedUsers := map[string]bool{"user1": true, "user2": true, "user3": true}
217+
for _, user := range users {
218+
if !expectedUsers[user] {
219+
t.Errorf("Unexpected user: %s", user)
220+
}
221+
delete(expectedUsers, user)
222+
}
223+
224+
if len(expectedUsers) > 0 {
225+
t.Errorf("Missing users: %v", expectedUsers)
226+
}
227+
228+
// Clean up
229+
_ = DeleteAll(service2)
230+
}
231+
232+
// TestListUsersEmpty tests listing users for a service with no secrets.
233+
func TestListUsersEmpty(t *testing.T) {
234+
const nonExistentService = "non-existent-service-12345"
235+
users, err := ListUsers(nonExistentService)
236+
if err != nil {
237+
t.Errorf("Should not fail on empty service, got: %s", err)
238+
}
239+
240+
if len(users) != 0 {
241+
t.Errorf("Expected 0 users for non-existent service, got %d", len(users))
242+
}
243+
}
244+
245+
// TestListUsersSingleUser tests listing users for a service with a single user.
246+
func TestListUsersSingleUser(t *testing.T) {
247+
const service3 = "test-service-single"
248+
err := Set(service3, "single-user", "password")
249+
if err != nil {
250+
t.Errorf("Should not fail, got: %s", err)
251+
}
252+
253+
users, err := ListUsers(service3)
254+
if err != nil {
255+
t.Errorf("Should not fail, got: %s", err)
256+
}
257+
258+
if len(users) != 1 {
259+
t.Errorf("Expected 1 user, got %d", len(users))
260+
}
261+
262+
if users[0] != "single-user" {
263+
t.Errorf("Expected user 'single-user', got '%s'", users[0])
264+
}
265+
266+
// Clean up
267+
_ = Delete(service3, "single-user")
268+
}
269+
270+
// TestListUsersMultipleServices tests that ListUsers only returns users for the specified service.
271+
func TestListUsersMultipleServices(t *testing.T) {
272+
const serviceA = "service-a"
273+
const serviceB = "service-b"
274+
275+
// Set up users for service A
276+
_ = Set(serviceA, "userA1", "passwordA1")
277+
_ = Set(serviceA, "userA2", "passwordA2")
278+
279+
// Set up users for service B
280+
_ = Set(serviceB, "userB1", "passwordB1")
281+
282+
// List users for service A
283+
usersA, err := ListUsers(serviceA)
284+
if err != nil {
285+
t.Errorf("Should not fail, got: %s", err)
286+
}
287+
288+
if len(usersA) != 2 {
289+
t.Errorf("Expected 2 users for service A, got %d", len(usersA))
290+
}
291+
292+
// Verify service A users don't include service B users
293+
for _, user := range usersA {
294+
if user == "userB1" {
295+
t.Errorf("Service A should not include users from service B")
296+
}
297+
}
298+
299+
// List users for service B
300+
usersB, err := ListUsers(serviceB)
301+
if err != nil {
302+
t.Errorf("Should not fail, got: %s", err)
303+
}
304+
305+
if len(usersB) != 1 {
306+
t.Errorf("Expected 1 user for service B, got %d", len(usersB))
307+
}
308+
309+
if usersB[0] != "userB1" {
310+
t.Errorf("Expected user 'userB1', got '%s'", usersB[0])
311+
}
312+
313+
// Clean up
314+
_ = DeleteAll(serviceA)
315+
_ = DeleteAll(serviceB)
316+
}
317+

keyring_unix.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,50 @@ func (s secretServiceProvider) DeleteAll(service string) error {
177177
return nil
178178
}
179179

180+
// ListUsers returns a list of all users for a given service
181+
func (s secretServiceProvider) ListUsers(service string) ([]string, error) {
182+
if service == "" {
183+
return []string{}, nil
184+
}
185+
186+
svc, err := ss.NewSecretService()
187+
if err != nil {
188+
return nil, err
189+
}
190+
191+
// Find all items for the service
192+
items, err := s.findServiceItems(svc, service)
193+
if err != nil {
194+
// If service has no items, return empty list instead of error
195+
if err == ErrNotFound {
196+
return []string{}, nil
197+
}
198+
return nil, err
199+
}
200+
201+
// Extract usernames from items
202+
var users []string
203+
seenUsers := make(map[string]bool)
204+
205+
for _, item := range items {
206+
// Get item attributes
207+
attrs, err := svc.GetItemAttributes(item)
208+
if err != nil {
209+
continue // Skip items we can't read
210+
}
211+
212+
// Extract username from attributes
213+
if username, ok := attrs["username"]; ok {
214+
if !seenUsers[username] {
215+
seenUsers[username] = true
216+
users = append(users, username)
217+
}
218+
}
219+
}
220+
221+
return users, nil
222+
}
223+
180224
func init() {
181225
provider = secretServiceProvider{}
182226
}

keyring_windows.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,35 @@ func (k windowsKeychain) DeleteAll(service string) error {
9393
return nil
9494
}
9595

96+
// ListUsers returns a list of all users for a given service
97+
func (k windowsKeychain) ListUsers(service string) ([]string, error) {
98+
if service == "" {
99+
return []string{}, nil
100+
}
101+
102+
creds, err := wincred.List()
103+
if err != nil {
104+
return nil, err
105+
}
106+
107+
prefix := k.credName(service, "")
108+
var users []string
109+
seenUsers := make(map[string]bool)
110+
111+
for _, cred := range creds {
112+
if strings.HasPrefix(cred.TargetName, prefix) {
113+
// Extract username from "service:username" format
114+
username := strings.TrimPrefix(cred.TargetName, prefix)
115+
if username != "" && !seenUsers[username] {
116+
seenUsers[username] = true
117+
users = append(users, username)
118+
}
119+
}
120+
}
121+
122+
return users, nil
123+
}
124+
96125
// credName combines service and username to a single string.
97126
func (k windowsKeychain) credName(service, username string) string {
98127
return service + ":" + username

secret_service/secret_service.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,22 @@ func (s *SecretService) GetSecret(itemPath dbus.ObjectPath, session dbus.ObjectP
240240
return &secret, nil
241241
}
242242

243+
// GetItemAttributes gets the attributes of an item.
244+
func (s *SecretService) GetItemAttributes(itemPath dbus.ObjectPath) (map[string]string, error) {
245+
obj := s.Object(serviceName, itemPath)
246+
variant, err := obj.GetProperty(itemInterface + ".Attributes")
247+
if err != nil {
248+
return nil, err
249+
}
250+
251+
attrs, ok := variant.Value().(map[string]string)
252+
if !ok {
253+
return nil, fmt.Errorf("failed to parse attributes")
254+
}
255+
256+
return attrs, nil
257+
}
258+
243259
// Delete deletes an item from the collection.
244260
func (s *SecretService) Delete(itemPath dbus.ObjectPath) error {
245261
var prompt dbus.ObjectPath

0 commit comments

Comments
 (0)