package kwallet import ( "context" "errors" "fmt" "strings" "github.com/godbus/dbus/v5" ) const ( kwalletInterface = "org.kde.KWallet" kwalletAppID = "email-mcp" kwalletFolderName = "email-mcp" ) var ( ErrKWalletUnavailable = errors.New("kwallet is not available") ErrKWalletDisabled = errors.New("kwallet is disabled") ErrKWalletOpenFailed = errors.New("kwallet could not be opened") ErrCredentialNotFound = errors.New("credentials not configured") ) type ClientImpl struct { probe func(context.Context) error open func(context.Context) error read func(context.Context, string) ([]byte, error) write func(context.Context, string, []byte) error } type dbusConnection interface { Object(dest string, path dbus.ObjectPath) dbusObject } type dbusObject interface { CallWithContext(ctx context.Context, method string, flags dbus.Flags, args ...any) *dbus.Call } type kwalletService struct { name string path dbus.ObjectPath } type walletSession struct { connect func() (dbusConnection, error) services []kwalletService object dbusObject handle int32 opened bool } type sessionBusConnection struct { conn *dbus.Conn } type sessionBusObject struct { object dbus.BusObject } func NewDefaultWalletClient() Client { return newClientImpl(newWalletSession(defaultSessionBusConnection)) } func newClientImpl(session *walletSession) ClientImpl { return ClientImpl{ probe: session.probe, open: session.open, read: session.readEntry, write: session.writeEntry, } } func newWalletSession(connect func() (dbusConnection, error)) *walletSession { return &walletSession{ connect: connect, services: []kwalletService{ {name: "org.kde.kwalletd6", path: "/modules/kwalletd6"}, {name: "org.kde.kwalletd5", path: "/modules/kwalletd5"}, }, } } func defaultSessionBusConnection() (dbusConnection, error) { conn, err := dbus.SessionBus() if err != nil { return nil, fmt.Errorf("%w: %v", ErrKWalletUnavailable, err) } return sessionBusConnection{conn: conn}, nil } func (c sessionBusConnection) Object(dest string, path dbus.ObjectPath) dbusObject { return sessionBusObject{object: c.conn.Object(dest, path)} } func (o sessionBusObject) CallWithContext(ctx context.Context, method string, flags dbus.Flags, args ...any) *dbus.Call { return o.object.CallWithContext(ctx, method, flags, args...) } func (c ClientImpl) IsAvailable(ctx context.Context) error { if c.probe == nil { return ErrKWalletUnavailable } return c.probe(ctx) } func (c ClientImpl) Open(ctx context.Context) error { if c.open == nil { return nil } return c.open(ctx) } func (c ClientImpl) WriteEntry(ctx context.Context, key string, value []byte) error { if c.write == nil { return fmt.Errorf("kwallet write operation is not configured") } return c.write(ctx, key, value) } func (c ClientImpl) ReadEntry(ctx context.Context, key string) ([]byte, error) { if c.read == nil { return nil, ErrCredentialNotFound } return c.read(ctx, key) } func (s *walletSession) probe(ctx context.Context) error { _, err := s.ensureObject(ctx) return err } func (s *walletSession) open(ctx context.Context) error { object, err := s.ensureObject(ctx) if err != nil { return err } if s.opened { return nil } walletName, err := s.walletName(ctx, object) if err != nil { return fmt.Errorf("%w: %v", ErrKWalletOpenFailed, err) } handle, err := s.callInt32(ctx, object, "open", walletName, int64(0), kwalletAppID) if err != nil { return fmt.Errorf("%w: %v", ErrKWalletOpenFailed, err) } if handle < 0 { return fmt.Errorf("%w: handle %d", ErrKWalletOpenFailed, handle) } hasFolder, err := s.callBool(ctx, object, "hasFolder", handle, kwalletFolderName, kwalletAppID) if err != nil { return fmt.Errorf("%w: %v", ErrKWalletOpenFailed, err) } if !hasFolder { created, err := s.callBool(ctx, object, "createFolder", handle, kwalletFolderName, kwalletAppID) if err != nil { return fmt.Errorf("%w: %v", ErrKWalletOpenFailed, err) } if !created { return fmt.Errorf("%w: folder %q could not be created", ErrKWalletOpenFailed, kwalletFolderName) } } s.handle = handle s.opened = true return nil } func (s *walletSession) writeEntry(ctx context.Context, key string, value []byte) error { if err := s.open(ctx); err != nil { return err } object, err := s.ensureObject(ctx) if err != nil { return err } code, err := s.callInt32(ctx, object, "writeEntry", s.handle, kwalletFolderName, key, value, kwalletAppID) if err != nil { return wrapUnavailable("kwallet write failed", err) } if code != 0 { return fmt.Errorf("kwallet write failed with code %d", code) } return nil } func (s *walletSession) readEntry(ctx context.Context, key string) ([]byte, error) { if err := s.open(ctx); err != nil { return nil, err } object, err := s.ensureObject(ctx) if err != nil { return nil, err } hasEntry, err := s.callBool(ctx, object, "hasEntry", s.handle, kwalletFolderName, key, kwalletAppID) if err != nil { return nil, wrapUnavailable("kwallet entry lookup failed", err) } if !hasEntry { return nil, fmt.Errorf("%w: key %q", ErrCredentialNotFound, key) } value, err := s.callBytes(ctx, object, "readEntry", s.handle, kwalletFolderName, key, kwalletAppID) if err != nil { return nil, wrapUnavailable("kwallet read failed", err) } return value, nil } func (s *walletSession) ensureObject(ctx context.Context) (dbusObject, error) { if s.object != nil { return s.object, nil } conn, err := s.connect() if err != nil { if errors.Is(err, ErrKWalletUnavailable) { return nil, err } return nil, fmt.Errorf("%w: %v", ErrKWalletUnavailable, err) } var lastErr error for _, service := range s.services { object := conn.Object(service.name, service.path) enabled, err := s.callBool(ctx, object, "isEnabled") if err != nil { lastErr = err continue } if !enabled { return nil, ErrKWalletDisabled } s.object = object return s.object, nil } if lastErr == nil { lastErr = errors.New("no kwallet service responded") } return nil, fmt.Errorf("%w: %v", ErrKWalletUnavailable, lastErr) } func (s *walletSession) walletName(ctx context.Context, object dbusObject) (string, error) { name, err := s.callString(ctx, object, "networkWallet") if err != nil { return "", err } name = strings.TrimSpace(name) if name != "" { return name, nil } name, err = s.callString(ctx, object, "localWallet") if err != nil { return "", err } name = strings.TrimSpace(name) if name == "" { return "", errors.New("kwallet did not report a wallet name") } return name, nil } func (s *walletSession) callBool(ctx context.Context, object dbusObject, name string, args ...any) (bool, error) { var value bool if err := object.CallWithContext(ctx, kwalletMethod(name), 0, args...).Store(&value); err != nil { return false, err } return value, nil } func (s *walletSession) callInt32(ctx context.Context, object dbusObject, name string, args ...any) (int32, error) { var value int32 if err := object.CallWithContext(ctx, kwalletMethod(name), 0, args...).Store(&value); err != nil { return 0, err } return value, nil } func (s *walletSession) callString(ctx context.Context, object dbusObject, name string, args ...any) (string, error) { var value string if err := object.CallWithContext(ctx, kwalletMethod(name), 0, args...).Store(&value); err != nil { return "", err } return value, nil } func (s *walletSession) callBytes(ctx context.Context, object dbusObject, name string, args ...any) ([]byte, error) { var value []byte if err := object.CallWithContext(ctx, kwalletMethod(name), 0, args...).Store(&value); err != nil { return nil, err } return value, nil } func kwalletMethod(name string) string { return kwalletInterface + "." + name } type typedError struct { message string errs []error } func (e *typedError) Error() string { return e.message } func (e *typedError) Unwrap() []error { return e.errs } func wrapUnavailable(message string, err error) error { if err == nil || errors.Is(err, ErrKWalletUnavailable) { return err } return &typedError{ message: fmt.Sprintf("%s: %v", message, err), errs: []error{ErrKWalletUnavailable, err}, } }