- 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_set7 -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 os4 -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 = None73 -1 self.db = sqlite3.connect(path)74 -1 os.chmod(path, 0o600)-1 79 self.path = path 75 80 self.prompt = Prompt() 76 8177 -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 -186 -1 def close(self):87 -1 self._crypt = None88 -1 self.db.close()89 -190 -1 def __enter__(self):91 -1 return self92 -193 -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 136104 -1 @property105 -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 AccessDeniedError112 -1 self._crypt = Crypt(password)113 -1 try:114 -1 self._validate_password()115 -1 except ValueError:116 -1 self._crypt = None117 -1 return self._crypt118 -1119 -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_000130 -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 -1136 -1 def validate_password(self):137 -1 # accessing the crypt will make sure that the password is validated138 -1 # FIXME: not nice139 -1 return self.crypt140 -1141 -1 def lock(self) -> None:142 -1 if not self._crypt:143 -1 raise ValueError144 -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 NotFoundError164 -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 NotFoundError172 -1 return self.crypt.decrypt(row[0])173 -1174 -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)