xi-keyring

simple and extensible alternative for gnome-keyring
git clone https://git.ce9e.org/xi-keyring.git

commit
38452867fe097b470d00951fbe0c3ac194ca3322
parent
81569ca7a740b022f6dd92a97818b77830ab5e12
Author
Tobias Bengfort <tobias.bengfort@posteo.de>
Date
2026-05-24 14:34
add a simple socket service

Diffstat

M PKGBUILD 1 +
M system/systemd.service 1 +
A system/systemd.socket 8 ++++++++
M xikeyring/__main__.py 12 ++++++++++--
M xikeyring/pidfd.py 16 ++++++++++++++++
A xikeyring/socket_service.py 114 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

6 files changed, 150 insertions, 2 deletions


diff --git a/PKGBUILD b/PKGBUILD

@@ -18,5 +18,6 @@ package() {
   18    18 	install -Dm 644 README.md "$pkgdir/usr/share/docs/xi-keyring/README.md"
   19    19 	install -Dm 644 system/dbus.service "$pkgdir/usr/share/dbus-1/services/org.xi.keyring.service"
   20    20 	install -Dm 644 system/systemd.service "$pkgdir/usr/lib/systemd/user/xi-keyring.service"
   -1    21 	install -Dm 644 system/systemd.socket "$pkgdir/usr/lib/systemd/user/xi-keyring.socket"
   21    22 	install -Dm 644 system/portal "$pkgdir/usr/share/xdg-desktop-portal/portals/xi-keyring.portal"
   22    23 }

diff --git a/system/systemd.service b/system/systemd.service

@@ -1,6 +1,7 @@
    1     1 [Unit]
    2     2 Description=xi keyring
    3     3 PartOf=graphical-session.target
   -1     4 Requires=xi-keyring.socket
    4     5 Requires=dbus.service
    5     6 After=dbus.service
    6     7 

diff --git a/system/systemd.socket b/system/systemd.socket

@@ -0,0 +1,8 @@
   -1     1 [Unit]
   -1     2 Description=xi keyring
   -1     3 
   -1     4 [Socket]
   -1     5 ListenStream=%t/xi.portal.Secret
   -1     6 
   -1     7 [Install]
   -1     8 WantedBy=sockets.target

diff --git a/xikeyring/__main__.py b/xikeyring/__main__.py

@@ -11,6 +11,7 @@ from .dbus import DBusService
   11    11 from .dumpable import pr_set
   12    12 from .keyring import KeyringProxy
   13    13 from .keyring import write_bytes
   -1    14 from .socket_service import SocketService
   14    15 
   15    16 
   16    17 def get_data_home():
@@ -45,6 +46,12 @@ def parse_args():
   45    46     parser.add_argument(
   46    47         '--bus', '-b', help='bus name', default='org.freedesktop.secrets'
   47    48     )
   -1    49     parser.add_argument(
   -1    50         '--socket',
   -1    51         help='socket path',
   -1    52         type=Path,
   -1    53         default=None,
   -1    54     )
   48    55     return parser.parse_args()
   49    56 
   50    57 
@@ -66,5 +73,6 @@ elif args.action == 'change-password':
   66    73     write_bytes(args.key, encrypted)
   67    74 else:
   68    75     with DBusService(keyring).own(args.bus):
   69    -1         loop = GLib.MainLoop()
   70    -1         loop.run()
   -1    76         with SocketService(keyring).listen(args.socket):
   -1    77             loop = GLib.MainLoop()
   -1    78             loop.run()

diff --git a/xikeyring/pidfd.py b/xikeyring/pidfd.py

@@ -1,12 +1,28 @@
    1     1 import selectors
   -1     2 import socket
   -1     3 import struct
    2     4 from pathlib import Path
    3     5 
   -1     6 try:
   -1     7     SO_PEERPIDFD = socket.SO_PEERPIDFD
   -1     8 except AttributeError:
   -1     9     SO_PEERPIDFD = 77
   -1    10 
    4    11 
    5    12 class PID:
    6    13     def __init__(self, pid: int, pidfd: int):
    7    14         self.pid = pid
    8    15         self.pidfd = pidfd
    9    16 
   -1    17     @classmethod
   -1    18     def from_socket(cls, sock: socket.socket) -> 'PID':
   -1    19         cred = sock.getsockopt(
   -1    20             socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize('3i')
   -1    21         )
   -1    22         pid, _uid, _gid = struct.unpack('3i', cred)
   -1    23         pidfd = sock.getsockopt(socket.SOL_SOCKET, SO_PEERPIDFD)
   -1    24         return cls(pid, pidfd)
   -1    25 
   10    26     def check_active(self) -> None:
   11    27         with selectors.DefaultSelector() as sel:
   12    28             sel.register(self.pidfd, selectors.EVENT_READ)

diff --git a/xikeyring/socket_service.py b/xikeyring/socket_service.py

@@ -0,0 +1,114 @@
   -1     1 import json
   -1     2 import socket
   -1     3 import sys
   -1     4 from contextlib import contextmanager
   -1     5 from pathlib import Path
   -1     6 
   -1     7 from gi.repository import GLib
   -1     8 
   -1     9 from .keyring import AccessDeniedError
   -1    10 from .keyring import Keyring
   -1    11 from .keyring import NotFoundError
   -1    12 from .pidfd import PID
   -1    13 
   -1    14 
   -1    15 class AmbiguousError(Exception):
   -1    16     pass
   -1    17 
   -1    18 
   -1    19 def watch(sock: socket.socket, callback) -> int:
   -1    20     sock.setblocking(False)  # noqa
   -1    21     chan = GLib.IOChannel.unix_new(sock.fileno())
   -1    22     flags = GLib.IO_IN|GLib.IO_HUP|GLib.IO_ERR
   -1    23     return GLib.io_add_watch(chan, flags, callback, sock)
   -1    24 
   -1    25 
   -1    26 class Connection:
   -1    27     def __init__(self, sock: socket.socket, keyring: Keyring):
   -1    28         self.sock = sock
   -1    29         self.keyring = keyring
   -1    30         self.pid = PID.from_socket(sock)
   -1    31         watch(sock, self.on_io)
   -1    32 
   -1    33     def get_id(self, query):
   -1    34         ids = self.keyring.search_items(self.pid, query)
   -1    35         if not ids:
   -1    36             raise NotFoundError
   -1    37         if len(ids) > 1:
   -1    38             raise AmbiguousError
   -1    39         return ids[0]
   -1    40 
   -1    41     def on_msg(self, msg):
   -1    42         if msg['method'] == 'get':
   -1    43             id = self.get_id(msg['query'])
   -1    44             secret = self.keyring.get_secret(self.pid, id)
   -1    45             return {'secret': secret.decode('utf-8')}
   -1    46         elif msg['method'] == 'set':
   -1    47             secret = msg['secret'].encode('utf-8')
   -1    48             try:
   -1    49                 id = self.get_id(msg['query'])
   -1    50                 self.keyring.update_secret(self.pid, id, secret)
   -1    51             except NotFoundError:
   -1    52                 self.keyring.create_item(self.pid, msg['query'], secret)
   -1    53             return {}
   -1    54         elif msg['method'] == 'del':
   -1    55             id = self.get_id(msg['query'])
   -1    56             self.keyring.delete_item(self.pid, id)
   -1    57             return {}
   -1    58         else:
   -1    59             return {'error': 'invalid request'}
   -1    60 
   -1    61     def on_io(self, source, flags, sock):
   -1    62         if flags & GLib.IO_ERR or flags & GLib.IO_HUP:
   -1    63             sock.close()
   -1    64             return False
   -1    65         chunk = sock.recv(1024)
   -1    66         if chunk:
   -1    67             # TODO buffer
   -1    68             try:
   -1    69                 msg = json.loads(chunk)
   -1    70                 reply = self.on_msg(msg)
   -1    71             except AccessDeniedError:
   -1    72                 reply = {'error': 'access denied'}
   -1    73             except NotFoundError:
   -1    74                 reply = {'error': 'not found'}
   -1    75             except AmbiguousError:
   -1    76                 reply = {'error': 'ambiguous'}
   -1    77             except Exception as e:
   -1    78                 print(e, file=sys.stderr)
   -1    79                 reply = {'error': 'server error'}
   -1    80             sock.send(json.dumps(reply).encode('utf-8'))
   -1    81             return True
   -1    82         else:
   -1    83             sock.close()
   -1    84             return False
   -1    85 
   -1    86 
   -1    87 class SocketService:
   -1    88     def __init__(self, keyring: Keyring):
   -1    89         self.keyring = keyring
   -1    90 
   -1    91     def on_con(self, source, flags, sock: socket.socket):
   -1    92         if flags & GLib.IO_ERR or flags & GLib.IO_HUP:
   -1    93             return False
   -1    94         conn, _addr = sock.accept()
   -1    95         Connection(conn, self.keyring)
   -1    96         return True
   -1    97 
   -1    98     @contextmanager
   -1    99     def listen(self, path: Path | None):
   -1   100         if path is None:  # systemd socket activation
   -1   101             with socket.fromfd(3, socket.AF_INET, socket.SOCK_STREAM) as sock:
   -1   102                 watch(sock, self.on_con)
   -1   103                 yield
   -1   104         else:
   -1   105             sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
   -1   106             sock.bind(str(path))
   -1   107             sock.listen()
   -1   108             print(f'listening on {path}')
   -1   109             try:
   -1   110                 watch(sock, self.on_con)
   -1   111                 yield
   -1   112             finally:
   -1   113                 sock.close()
   -1   114                 path.unlink()