xi-keyring

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

commit
58259b8b0382a52b548bf1799564c1b058eb97b2
parent
79eaa548d2b314fe4c4e5207ab85cbde86f4ebfe
Author
Tobias Bengfort <tobias.bengfort@posteo.de>
Date
2024-04-07 14:04
use an encrypted json file instead of sqlite with encrypted secrets

- pros
  - meta data is encrypted
  - it is more straight forward to check the password once on unlocking
- con
  - all secrets are in memory when unlocked
  - search performance is probably worse now
  - concurrent access to the store is no longer possible

Diffstat

M xikeyring/__main__.py 8 ++++----
M xikeyring/keyring.py 215 ++++++++++++++++++++++++++++---------------------------------

2 files changed, 104 insertions, 119 deletions


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

@@ -4,7 +4,7 @@ from pathlib import Path
    4     4 
    5     5 from .dbus import DBusService
    6     6 from .dumpable import pr_set
    7    -1 from .keyring import Keyring
   -1     7 from .keyring import KeyringProxy
    8     8 
    9     9 
   10    10 def get_data_home():
@@ -32,6 +32,6 @@ def parse_args():
   32    32 pr_set(dumpable=False)
   33    33 
   34    34 args = parse_args()
   35    -1 with Keyring(args.store) as keyring:
   36    -1     service = DBusService(keyring)
   37    -1     service.run(args.bus)
   -1    35 keyring = KeyringProxy(args.store)
   -1    36 service = DBusService(keyring)
   -1    37 service.run(args.bus)

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

@@ -1,9 +1,10 @@
    1     1 import base64
    2     2 import json
    3     3 import os
    4    -1 import sqlite3
   -1     4 from dataclasses import dataclass
    5     5 
    6     6 from cryptography.fernet import Fernet
   -1     7 from cryptography.fernet import InvalidToken
    7     8 from cryptography.hazmat.primitives import hashes
    8     9 from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
    9    10 
@@ -18,6 +19,12 @@ class NotFoundError(Exception):
   18    19     pass
   19    20 
   20    21 
   -1    22 @dataclass
   -1    23 class Item:
   -1    24     secret: bytes
   -1    25     attributes: dict[str, str]
   -1    26 
   -1    27 
   21    28 class Crypt:
   22    29     def __init__(self, password: bytes):
   23    30         self.password = password
@@ -66,32 +73,58 @@ class Crypt:
   66    73 
   67    74 
   68    75 class Keyring:
   69    -1     _crypt: Crypt | None
   -1    76     items: dict[int, Item]
   70    77 
   71    78     def __init__(self, path: str):
   72    -1         self._crypt = None
   73    -1         self.db = sqlite3.connect(path)
   74    -1         os.chmod(path, 0o600)
   -1    79         self.path = path
   75    80         self.prompt = Prompt()
   76    81 
   77    -1         with self.db:
   78    -1             self.db.execute(
   79    -1                 'CREATE TABLE IF NOT EXISTS items('
   80    -1                 'id INTEGER PRIMARY KEY, attributes JSON, secret BLOB)'
   81    -1             )
   82    -1             self.db.execute(
   83    -1                 'CREATE TABLE IF NOT EXISTS meta(id INTEGER PRIMARY KEY, value BLOB)'
   -1    82         if os.path.exists(self.path):
   -1    83             while True:
   -1    84                 self.crypt = self._get_crypt()
   -1    85                 try:
   -1    86                     self._read()
   -1    87                     break
   -1    88                 except InvalidToken:
   -1    89                     pass
   -1    90         else:
   -1    91             self.items = {}
   -1    92             self.crypt = self._get_crypt()
   -1    93             self._write()
   -1    94             os.chmod(self.path, 0o600)
   -1    95 
   -1    96     def _get_crypt(self):
   -1    97         # TODO: different messages for create|unlock|retry
   -1    98         password = self.prompt.get_password(
   -1    99             'An application wants access to your keyring, but it is locked'
   -1   100         )
   -1   101         if not password:
   -1   102             raise AccessDeniedError
   -1   103         return Crypt(password)
   -1   104 
   -1   105     def _read(self):
   -1   106         with open(self.path, 'rb') as fh:
   -1   107             encrypted = fh.read()
   -1   108         decrypted = self.crypt.decrypt(encrypted)
   -1   109         raw = json.loads(decrypted)
   -1   110         self.items = {
   -1   111             id: Item(base64.urlsafe_b64decode(secret), attributes)
   -1   112             for id, secret, attributes in raw
   -1   113         }
   -1   114 
   -1   115     def _write(self):
   -1   116         raw = [
   -1   117             (
   -1   118                 id,
   -1   119                 base64.urlsafe_b64encode(item.secret).decode(),
   -1   120                 item.attributes,
   84   121             )
   85    -1 
   86    -1     def close(self):
   87    -1         self._crypt = None
   88    -1         self.db.close()
   89    -1 
   90    -1     def __enter__(self):
   91    -1         return self
   92    -1 
   93    -1     def __exit__(self, type, value, traceback):
   94    -1         self.close()
   -1   122             for id, item in self.items.items()
   -1   123         ]
   -1   124         decrypted = json.dumps(raw).encode('utf-8')
   -1   125         encrypted = self.crypt.encrypt(decrypted)
   -1   126         with open(self.path, 'wb') as fh:
   -1   127             fh.write(encrypted)
   95   128 
   96   129     def confirm_access(self) -> None:
   97   130         if not self.prompt.confirm('Allow access to secret from keyring?'):
@@ -101,116 +134,68 @@ class Keyring:
  101   134         if not self.prompt.confirm('Allow changes to keyring?'):
  102   135             raise AccessDeniedError
  103   136 
  104    -1     @property
  105    -1     def crypt(self) -> Crypt:
  106    -1         while not self._crypt:
  107    -1             password = self.prompt.get_password(
  108    -1                 'An application wants access to your keyring, but it is locked'
  109    -1             )
  110    -1             if not password:
  111    -1                 raise AccessDeniedError
  112    -1             self._crypt = Crypt(password)
  113    -1             try:
  114    -1                 self._validate_password()
  115    -1             except ValueError:
  116    -1                 self._crypt = None
  117    -1         return self._crypt
  118    -1 
  119    -1     def _validate_password(self) -> None:
  120    -1         # SECURITY: we use the same mechanism to derive encryption keys.
  121    -1         # Is this secure?
  122    -1         res = self.db.execute('SELECT value FROM meta WHERE id=1')
  123    -1         row = res.fetchone()
  124    -1         if row:
  125    -1             salt, iterations, content = self.crypt.decode(row[0])
  126    -1             if self.crypt.get_key(salt, iterations) != content:
  127    -1                 raise ValueError('incorect password')
  128    -1         else:
  129    -1             iterations = 480_000
  130    -1             salt = os.urandom(32)
  131    -1             key = self.crypt.get_key(salt, iterations)
  132    -1             data = self.crypt.encode(salt, iterations, key)
  133    -1             with self.db:
  134    -1                 self.db.execute('INSERT INTO meta(id, value) VALUES (1, ?)', [data])
  135    -1 
  136    -1     def validate_password(self):
  137    -1         # accessing the crypt will make sure that the password is validated
  138    -1         # FIXME: not nice
  139    -1         return self.crypt
  140    -1 
  141    -1     def lock(self) -> None:
  142    -1         if not self._crypt:
  143    -1             raise ValueError
  144    -1         self._crypt = None
   -1   137     def __getitem__(self, id: int) -> Item:
   -1   138         try:
   -1   139             return self.items[id]
   -1   140         except KeyError as e:
   -1   141             raise NotFoundError from e
  145   142 
  146   143     def search_items(self, query: dict[str, str] = {}) -> list[int]:
  147    -1         params = []
  148    -1         sql = 'SELECT id FROM items'
  149    -1         if query:
  150    -1             for key, value in query.items():
  151    -1                 params.append(f'$.{key}')
  152    -1                 params.append(value)
  153    -1             sql += ' WHERE ' + ' AND '.join(
  154    -1                 ['json_extract(attributes, ?) = ?' for _ in query]
   -1   144         return [
   -1   145             id for id, item in self.items.items()
   -1   146             if not query or all(
   -1   147                 item.attributes.get(key) == value for key, value in query.items()
  155   148             )
  156    -1         res = self.db.execute(sql, params)
  157    -1         return [row[0] for row in res.fetchall()]
   -1   149         ]
  158   150 
  159   151     def get_attributes(self, id: int) -> dict[str, str]:
  160    -1         res = self.db.execute('SELECT attributes FROM items WHERE id = ?', [id])
  161    -1         row = res.fetchone()
  162    -1         if not row:
  163    -1             raise NotFoundError
  164    -1         return json.loads(row[0])
   -1   152         return self[id].attributes
  165   153 
  166   154     def get_secret(self, id: int) -> bytes:
  167   155         self.confirm_access()
  168    -1         res = self.db.execute('SELECT secret FROM items WHERE id = ?', [id])
  169    -1         row = res.fetchone()
  170    -1         if not row:
  171    -1             raise NotFoundError
  172    -1         return self.crypt.decrypt(row[0])
  173    -1 
  174    -1     def create_item(self, attributes: dict[str, str], secret: bytes):
  175    -1         self.validate_password()
  176    -1         with self.db:
  177    -1             cur = self.db.cursor()
  178    -1             cur.execute(
  179    -1                 'INSERT INTO items(attributes, secret) VALUES (json(?), ?)',
  180    -1                 [
  181    -1                     json.dumps(attributes),
  182    -1                     self.crypt.encrypt(secret),
  183    -1                 ],
  184    -1             )
  185    -1             return cur.lastrowid
   -1   156         return self[id].secret
   -1   157 
   -1   158     def create_item(self, attributes: dict[str, str], secret: bytes) -> int:
   -1   159         id = max(self.items.keys(), default=0) + 1
   -1   160         self.items[id] = Item(secret, attributes)
   -1   161         self._write()
   -1   162         return id
  186   163 
  187   164     def update_attributes(self, id: int, attributes: dict[str, str]) -> None:
  188   165         self.confirm_change()
  189    -1         self.validate_password()
  190    -1         with self.db:
  191    -1             self.db.execute(
  192    -1                 'UPDATE items SET attributes=json(?) WHERE id=?',
  193    -1                 [json.dumps(attributes), id],
  194    -1             )
   -1   166         self[id].attributes = attributes
   -1   167         self._write()
  195   168 
  196   169     def update_secret(self, id: int, secret: bytes) -> None:
  197   170         self.confirm_change()
  198    -1         self.validate_password()
  199    -1         with self.db:
  200    -1             self.db.execute(
  201    -1                 'UPDATE items SET secret=? WHERE id=?',
  202    -1                 [self.crypt.encrypt(secret), id],
  203    -1             )
   -1   171         self[id].secret = secret
   -1   172         self._write()
  204   173 
  205   174     def delete_item(self, id: int) -> None:
  206   175         self.confirm_change()
  207    -1         self.validate_password()
  208    -1         with self.db:
  209    -1             self.db.execute('DELETE FROM items WHERE id=?', [id])
   -1   176         try:
   -1   177             del self.items[id]
   -1   178         except KeyError as e:
   -1   179             raise NotFoundError from e
   -1   180         self._write()
   -1   181 
   -1   182 
   -1   183 class KeyringProxy:
   -1   184     def __init__(self, path):
   -1   185         self.path = path
   -1   186         self.keyring = None
   -1   187 
   -1   188     def lock(self):
   -1   189         self.keyring = None
   -1   190 
   -1   191     def __getattr__(self, attr):
   -1   192         if self.keyring is None:
   -1   193             self.keyring = Keyring(self.path)
   -1   194         return getattr(self.keyring, attr)
  210   195 
  211   196 
  212   197 if __name__ == '__main__':
  213    -1     with Keyring('keyring.db') as k:
  214    -1         id = k.create_item({'foo': 'bar'}, b'password')
  215    -1         print(k.get_secret(id))
  216    -1         k.delete_item(id)
   -1   198     k = KeyringProxy('keyring.db')
   -1   199     id = k.create_item({'foo': 'bar'}, b'password')
   -1   200     print(k.get_secret(id))
   -1   201     k.delete_item(id)