xi-keyring

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

commit
492599030df9af81ce8bb585c9fac4188f3da221
parent
88faaf1ead8d2e57d79e54de612bd494848872f6
Author
Tobias Bengfort <tobias.bengfort@posteo.de>
Date
2026-05-24 08:08
breaking: store key in separate file

See https://github.com/xi/xi-keyring/pull/5 for rationale

To migrate, follow these steps:

- python3 -m xikeyring --dump > backup.json
- update to the new version
- python3 -m xikeyring --restore < backup.json
- verify that everything works as expected
- rm backup.json
- rm ~/.local/share/xikeyring.db

Diffstat

M xikeyring/__main__.py 18 +++++++++++++-----
M xikeyring/keyring.py 57 ++++++++++++++++++++++++++++++++++++---------------------

2 files changed, 49 insertions, 26 deletions


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

@@ -3,7 +3,8 @@ import os
    3     3 import sys
    4     4 from pathlib import Path
    5     5 
    6    -1 from . import crypto
   -1     6 from cryptography.fernet import Fernet
   -1     7 
    7     8 from .dbus import DBusService
    8     9 from .dumpable import pr_set
    9    10 from .keyring import KeyringProxy
@@ -35,7 +36,14 @@ def parse_args():
   35    36         '-s',
   36    37         help='path to the store file',
   37    38         type=Path,
   38    -1         default=get_data_home() / 'xikeyring.db',
   -1    39         default=get_data_home() / 'xikeyring' / 'store',
   -1    40     )
   -1    41     parser.add_argument(
   -1    42         '--key',
   -1    43         '-k',
   -1    44         help='path to the key file',
   -1    45         type=Path,
   -1    46         default=get_data_home() / 'xikeyring' / 'key',
   39    47     )
   40    48     parser.add_argument(
   41    49         '--bus', '-b', help='bus name', default='org.freedesktop.secrets'
@@ -46,14 +54,14 @@ def parse_args():
   46    54 pr_set(dumpable=False)
   47    55 
   48    56 args = parse_args()
   49    -1 keyring = KeyringProxy(args.store)
   -1    57 keyring = KeyringProxy(args.store, args.key)
   50    58 if args.dump:
   51    59     encrypted = keyring.path.read_bytes()
   52    -1     decrypted = crypto.decrypt_with_password(encrypted, keyring.password.value)
   -1    60     decrypted = Fernet(keyring.key.value).decrypt(encrypted)
   53    61     print(decrypted.decode('utf-8'))
   54    62 elif args.restore:
   55    63     decrypted = sys.stdin.read().encode('utf-8')
   56    -1     encrypted = crypto.encrypt_with_password(decrypted, keyring.password.value)
   -1    64     encrypted = Fernet(keyring.key.value).encrypt(decrypted)
   57    65     write_bytes(keyring.path, encrypted)
   58    66 else:
   59    67     service = DBusService(keyring)

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

@@ -4,6 +4,7 @@ import os
    4     4 from dataclasses import dataclass
    5     5 from pathlib import Path
    6     6 
   -1     7 from cryptography.fernet import Fernet
    7     8 from cryptography.fernet import InvalidToken
    8     9 
    9    10 from . import crypto
@@ -40,34 +41,48 @@ def write_bytes(path: Path, data: bytes) -> int:
   40    41 
   41    42 
   42    43 class Keyring:
   43    -1     def __init__(self, path: Path):
   44    -1         self.path = path
   -1    44     def __init__(self, store_path: Path, key_path: Path):
   -1    45         self.path = store_path
   45    46         self.prompt = Prompt()
   46    47 
   47    -1         if self.path.exists():
   48    -1             while True:
   49    -1                 self.password = self._get_password()
   50    -1                 try:
   51    -1                     self._read()
   52    -1                     break
   53    -1                 except InvalidToken:
   54    -1                     pass
   -1    48         if key_path.exists():
   -1    49             self.key = self._get_key(key_path)
   55    50         else:
   56    -1             self.password = self._get_password()
   57    -1             self._write({})
   -1    51             self.key = self._create_key(key_path)
   58    52 
   59    -1     def _get_password(self):
   60    -1         # TODO: different messages for create|unlock|retry
   -1    53     def _get_key(self, path: Path) -> KernelKey:
   -1    54         encrypted = path.read_bytes()
   -1    55         while True:
   -1    56             password = self.prompt.get_password(
   -1    57                 'An application wants access to your keyring, but it is locked.'
   -1    58             )
   -1    59             if not password:
   -1    60                 raise AccessDeniedError
   -1    61             try:
   -1    62                 key = crypto.decrypt_with_password(encrypted, password)
   -1    63                 return KernelKey(key)
   -1    64             except InvalidToken:
   -1    65                 pass
   -1    66 
   -1    67     def _create_key(self, path: Path) -> KernelKey:
   61    68         password = self.prompt.get_password(
   62    -1             'An application wants access to your keyring, but it is locked'
   -1    69             'An application wants access to your keyring. '
   -1    70             'Please enter a password to create a keyring.'
   63    71         )
   64    72         if not password:
   65    73             raise AccessDeniedError
   66    -1         return KernelKey(password)
   -1    74         key = Fernet.generate_key()
   -1    75         encrypted = crypto.encrypt_with_password(key, password)
   -1    76         path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
   -1    77         write_bytes(path, encrypted)
   -1    78         return KernelKey(key)
   67    79 
   68    80     def _read(self) -> dict[int, Item]:
   -1    81         if not self.path.exists():
   -1    82             return {}
   -1    83 
   69    84         encrypted = self.path.read_bytes()
   70    -1         decrypted = crypto.decrypt_with_password(encrypted, self.password.value)
   -1    85         decrypted = Fernet(self.key.value).decrypt(encrypted)
   71    86         raw = json.loads(decrypted)
   72    87         return {
   73    88             id: Item(base64.urlsafe_b64decode(secret), attributes, app_id)
@@ -85,7 +100,7 @@ class Keyring:
   85   100             for id, item in items.items()
   86   101         ]
   87   102         decrypted = json.dumps(raw).encode('utf-8')
   88    -1         encrypted = crypto.encrypt_with_password(decrypted, self.password.value)
   -1   103         encrypted = Fernet(self.key.value).encrypt(decrypted)
   89   104         write_bytes(self.path, encrypted)
   90   105 
   91   106     def confirm_access(self, app_id: str) -> None:
@@ -157,8 +172,8 @@ class Keyring:
  157   172 
  158   173 
  159   174 class KeyringProxy:
  160    -1     def __init__(self, path: Path):
  161    -1         self.path = path
   -1   175     def __init__(self, *args):
   -1   176         self.args = args
  162   177         self.keyring = None
  163   178 
  164   179     def lock(self):
@@ -166,5 +181,5 @@ class KeyringProxy:
  166   181 
  167   182     def __getattr__(self, attr):
  168   183         if self.keyring is None:
  169    -1             self.keyring = Keyring(self.path)
   -1   184             self.keyring = Keyring(*self.args)
  170   185         return getattr(self.keyring, attr)