email-mcp/internal/secretstore/kwallet/client.go
2026-04-10 11:06:39 +02:00

343 lines
8.2 KiB
Go

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 s.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, s.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, s.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 (s *walletSession) reset() {
s.object = nil
s.handle = 0
s.opened = false
}
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 (s *walletSession) wrapUnavailable(message string, err error) error {
if err == nil {
return err
}
s.reset()
if errors.Is(err, ErrKWalletUnavailable) {
return err
}
return &typedError{
message: fmt.Sprintf("%s: %v", message, err),
errs: []error{ErrKWalletUnavailable, err},
}
}