- commit
- 054bbd0a3cd6866cb5594120f0bc27e0e893ed34
- parent
- 7029af0972752ce5a83855d1bddf43099788fb55
- Author
- Tobias Bengfort <tobias.bengfort@posteo.de>
- Date
- 2024-03-28 08:03
init
Diffstat
| A | .gitignore | 3 | +++ |
| A | xikeyring/__init__.py | 0 | |
| A | xikeyring/__main__.py | 7 | +++++++ |
| A | xikeyring/dbus.py | 279 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | xikeyring/dbus_sessions.py | 98 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | xikeyring/keyring.py | 219 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | xikeyring/org.freedesktop.Secrets.xml | 146 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | xikeyring/prompt.py | 80 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
8 files changed, 832 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1,3 @@ -1 1 /target -1 2 *.db -1 3 *.lock
diff --git a/xikeyring/__init__.py b/xikeyring/__init__.py
diff --git a/xikeyring/__main__.py b/xikeyring/__main__.py
@@ -0,0 +1,7 @@
-1 1 from .dbus import DBusService
-1 2 from .keyring import Keyring
-1 3
-1 4 with Keyring('keyring.db') as keyring:
-1 5 service = DBusService(keyring)
-1 6 # service.run('org.freedesktop.secrets')
-1 7 service.run('org.ce9e.keyring')
diff --git a/xikeyring/dbus.py b/xikeyring/dbus.py
@@ -0,0 +1,279 @@
-1 1 import logging
-1 2 import re
-1 3 import sys
-1 4 from pathlib import Path
-1 5
-1 6 import gi
-1 7 from gi.repository import Gio
-1 8 from gi.repository import GLib
-1 9
-1 10 from .dbus_sessions import create_session
-1 11 from .keyring import AccessDeniedError
-1 12 from .keyring import NotFoundError
-1 13
-1 14 gi.require_version('Gtk', '3.0')
-1 15
-1 16 logger = logging.getLogger(__name__)
-1 17 logging.basicConfig()
-1 18
-1 19 with (Path(__file__).parent / 'org.freedesktop.Secrets.xml').open() as fh:
-1 20 INFO_XML = fh.read()
-1 21
-1 22
-1 23 class BaseDBusService:
-1 24 def __init__(self, xml):
-1 25 self.info = Gio.DBusNodeInfo.new_for_xml(xml)
-1 26
-1 27 def register_object(self, conn, path, iface):
-1 28 return conn.register_object(
-1 29 path,
-1 30 self.info.lookup_interface(iface),
-1 31 self.call,
-1 32 self.get_prop,
-1 33 self.set_prop,
-1 34 )
-1 35
-1 36 def on_bus_acquired(self, conn, bus, user_data=None):
-1 37 print(f'bus {bus} acquired')
-1 38
-1 39 def on_name_lost(self, conn, name, user_data=None):
-1 40 sys.exit(f'Could not aquire name {name}. Is some other service blocking it?')
-1 41
-1 42 def run(self, name):
-1 43 handle = Gio.bus_own_name(
-1 44 Gio.BusType.SESSION,
-1 45 name,
-1 46 Gio.BusNameOwnerFlags.NONE,
-1 47 self.on_bus_acquired,
-1 48 None,
-1 49 self.on_name_lost,
-1 50 )
-1 51
-1 52 try:
-1 53 loop = GLib.MainLoop()
-1 54 loop.run()
-1 55 finally:
-1 56 Gio.bus_unown_name(handle)
-1 57
-1 58 def _call(self, conn, sender, path, iface, member, args, error):
-1 59 try:
-1 60 key = iface.rsplit('.', 1)[1] + member
-1 61 key = re.sub(r'([A-Z])', r'_\1', key).strip('_').lower()
-1 62 fn = getattr(self, key)
-1 63 except AttributeError as e:
-1 64 raise GLib.Error(
-1 65 domain=Gio.dbus_error_quark(),
-1 66 code=error,
-1 67 ) from e
-1 68 try:
-1 69 return fn(conn, sender, path, *args)
-1 70 except NotFoundError as e:
-1 71 raise GLib.Error(
-1 72 domain=Gio.dbus_error_quark(),
-1 73 code=Gio.DBusError.UNKNOWN_OBJECT,
-1 74 ) from e
-1 75 except AccessDeniedError as e:
-1 76 raise GLib.Error(
-1 77 domain=Gio.dbus_error_quark(),
-1 78 code=Gio.DBusError.ACCESS_DENIED,
-1 79 ) from e
-1 80 except Exception as e:
-1 81 logger.exception(e)
-1 82 raise GLib.Error(
-1 83 domain=Gio.dbus_error_quark(),
-1 84 code=Gio.DBusError.FAILED,
-1 85 ) from e
-1 86
-1 87 def call(self, conn, sender, path, iface, method, params, invocation):
-1 88 try:
-1 89 error = Gio.DBusError.UNKNOWN_METHOD
-1 90 result = self._call(conn, sender, path, iface, method, params, error)
-1 91 invocation.return_value(result)
-1 92 except GLib.Error as e:
-1 93 invocation.return_error_literal(e.domain, e.code, e.message)
-1 94
-1 95 def get_prop(self, conn, sender, path, iface, prop):
-1 96 error = Gio.DBusError.UNKNOWN_PROPERTY
-1 97 return self._call(conn, sender, path, iface, f'Get{prop}', [], error)
-1 98
-1 99 def set_prop(self, conn, sender, path, iface, prop, value):
-1 100 error = Gio.DBusError.UNKNOWN_PROPERTY
-1 101 self._call(conn, sender, path, iface, f'Set{prop}', [value], error)
-1 102 return True
-1 103
-1 104
-1 105 class DBusService(BaseDBusService):
-1 106 def __init__(self, keyring):
-1 107 super().__init__(INFO_XML)
-1 108 self.keyring = keyring
-1 109 self.sessions = {}
-1 110 self.registered_items = {}
-1 111 self.session_counter = 0
-1 112
-1 113 def ids_to_paths(self, items):
-1 114 return [f'/org/freedesktop/secrets/collection/it/{id}' for id in items]
-1 115
-1 116 def update_items(self, conn, *, keep=None, add=[], rm=[]):
-1 117 for id, reg_id in list(self.registered_items.items()):
-1 118 if id in rm or (keep is not None and id not in keep):
-1 119 conn.unregister_object(reg_id)
-1 120 del self.registered_items[id]
-1 121
-1 122 for id in add:
-1 123 if id not in self.registered_items:
-1 124 self.registered_items[id] = self.register_object(
-1 125 conn,
-1 126 f'/org/freedesktop/secrets/collection/it/{id}',
-1 127 'org.freedesktop.Secret.Item',
-1 128 )
-1 129
-1 130 def on_bus_acquired(self, conn, bus):
-1 131 super().on_bus_acquired(conn, bus)
-1 132 self.register_object(
-1 133 conn, '/org/freedesktop/secrets', 'org.freedesktop.Secret.Service'
-1 134 )
-1 135 self.register_object(
-1 136 conn,
-1 137 '/org/freedesktop/secrets/aliases/default',
-1 138 'org.freedesktop.Secret.Collection',
-1 139 )
-1 140 self.register_object(
-1 141 conn,
-1 142 '/org/freedesktop/secrets/collection/it',
-1 143 'org.freedesktop.Secret.Collection',
-1 144 )
-1 145
-1 146 items = self.keyring.list_items()
-1 147 self.update_items(conn, keep=items, add=items)
-1 148
-1 149 def service_open_session(self, conn, sender, path, algorithm, input):
-1 150 output, session = create_session(algorithm, input)
-1 151 self.session_counter += 1
-1 152 session_path = f'/org/freedesktop/secrets/sessions/{self.session_counter}'
-1 153 self.sessions[session_path] = session
-1 154 self.register_object(conn, session_path, 'org.freedesktop.Secret.Session')
-1 155 return GLib.Variant('(vo)', (GLib.Variant('ay', output), session_path))
-1 156
-1 157 def service_search_items(self, conn, sender, path, query):
-1 158 items = self.keyring.search_items(query)
-1 159 self.update_items(conn, add=items)
-1 160 return GLib.Variant('(aoao)', (self.ids_to_paths(items), []))
-1 161
-1 162 def service_unlock(self, conn, sender, path, objects):
-1 163 return GLib.Variant('(aoo)', (objects, '/'))
-1 164
-1 165 def service_lock(self, conn, sender, path, objects):
-1 166 self.keyring.lock()
-1 167 return GLib.Variant('(aoo)', ([], '/'))
-1 168
-1 169 def service_get_secrets(self, conn, sender, path, items, session_path):
-1 170 session = self.sessions[session_path]
-1 171 result = []
-1 172 for path in items:
-1 173 id = int(path.rsplit('/', 1)[1], 10)
-1 174 secret = self.keyring.get_secret(id)
-1 175 secret_tuple = session.encode(session_path, secret)
-1 176 result.append((path, secret_tuple))
-1 177 return GLib.Variant('(a{o(oayays)})', [result])
-1 178
-1 179 def service_read_alias(self, conn, sender, path, name):
-1 180 if name == 'default':
-1 181 return GLib.Variant('(o)', ['/org/freedesktop/secrets/collection/it'])
-1 182 else:
-1 183 return GLib.Variant('(o)', ['/'])
-1 184
-1 185 def service_get_collections(self, conn, sender, path):
-1 186 return GLib.Variant('ao', ['/org/freedesktop/secrets/collection/it'])
-1 187
-1 188 def collection_search_items(self, conn, sender, path, query):
-1 189 items = self.keyring.search_items(query)
-1 190 return GLib.Variant('(ao)', [self.ids_to_paths(items)])
-1 191
-1 192 def collection_create_item(
-1 193 self, conn, sender, path, properties, secret_tuple, replace
-1 194 ):
-1 195 session = self.sessions[secret_tuple[0]]
-1 196 secret = session.decode(secret_tuple)
-1 197 attributes = properties.get('org.freedesktop.Secret.Item.Attributes', {})
-1 198 id = None
-1 199 if replace:
-1 200 matches = self.keyring.search_items(attributes)
-1 201 if matches:
-1 202 id = matches[0]
-1 203 self.keyring.update_secret(id, secret)
-1 204 if not id:
-1 205 id = self.keyring.create_item(attributes, secret)
-1 206 self.update_items(conn, add=[id])
-1 207 # TODO: trigger signal
-1 208 return GLib.Variant(
-1 209 '(oo)', (f'/org/freedesktop/secrets/collection/it/{id}', '/')
-1 210 )
-1 211
-1 212 def collection_get_items(self, conn, sender, path):
-1 213 items = self.keyring.list_items()
-1 214 self.update_items(conn, keep=items, add=items)
-1 215 return GLib.Variant(
-1 216 'ao',
-1 217 [f'/org/freedesktop/secrets/collection/it/{id}' for id in items],
-1 218 )
-1 219
-1 220 def collection_get_label(self, conn, sender, path):
-1 221 return GLib.Variant('s', 'it')
-1 222
-1 223 def collection_get_created(self, conn, sender, path):
-1 224 return GLib.Variant('t', 0)
-1 225
-1 226 def collection_get_modified(self, conn, sender, path):
-1 227 return GLib.Variant('t', 0)
-1 228
-1 229 def collection_get_locked(self, conn, sender, path):
-1 230 return GLib.Variant('b', value=False)
-1 231
-1 232 def item_delete(self, conn, sender, path):
-1 233 id = int(path.rsplit('/', 1)[1], 10)
-1 234 self.keyring.delete_item(id)
-1 235 self.update_items(conn, rm=[id])
-1 236 return GLib.Variant('(o)', ['/'])
-1 237 # TODO: trigger signal
-1 238
-1 239 def item_get_secret(self, conn, sender, path, session_path):
-1 240 id = int(path.rsplit('/', 1)[1], 10)
-1 241 secret = self.keyring.get_secret(id)
-1 242 session = self.sessions[session_path]
-1 243 secret_tuple = session.encode(session_path, secret)
-1 244 return GLib.Variant('((oayays))', [secret_tuple])
-1 245
-1 246 def item_set_secret(self, conn, sender, path, secret_tuple):
-1 247 id = int(path.rsplit('/', 1)[1], 10)
-1 248 session = self.sessions[secret_tuple[0]]
-1 249 secret = session.decode(secret_tuple)
-1 250 self.keyring.update_secret(id, secret)
-1 251 # TODO: trigger signal
-1 252
-1 253 def item_get_label(self, conn, sender, path):
-1 254 return GLib.Variant('s', path.rsplit('/', 1)[1])
-1 255
-1 256 def item_get_type(self, conn, sender, path):
-1 257 return GLib.Variant('s', 'org.freedesktop.Secret.Generic')
-1 258
-1 259 def item_get_created(self, conn, sender, path):
-1 260 return GLib.Variant('t', 0)
-1 261
-1 262 def item_get_modified(self, conn, sender, path):
-1 263 return GLib.Variant('t', 0)
-1 264
-1 265 def item_get_locked(self, conn, sender, path):
-1 266 return GLib.Variant('b', value=False)
-1 267
-1 268 def item_get_attributes(self, conn, sender, path):
-1 269 id = int(path.rsplit('/', 1)[1], 10)
-1 270 attributes = self.keyring.get_attributes(id)
-1 271 return GLib.Variant('a{ss}', attributes.items())
-1 272
-1 273 def item_set_attributes(self, conn, sender, path, value):
-1 274 id = int(path.rsplit('/', 1)[1], 10)
-1 275 self.keyring.update_attributes(id, value.unpack())
-1 276 # TODO: trigger signal
-1 277
-1 278 def session_close(self, conn, sender, path):
-1 279 del self.sessions[path]
diff --git a/xikeyring/dbus_sessions.py b/xikeyring/dbus_sessions.py
@@ -0,0 +1,98 @@
-1 1 import os
-1 2
-1 3 from cryptography.hazmat.primitives import hashes
-1 4 from cryptography.hazmat.primitives import padding
-1 5 from cryptography.hazmat.primitives.asymmetric import dh
-1 6 from cryptography.hazmat.primitives.ciphers import Cipher
-1 7 from cryptography.hazmat.primitives.ciphers import algorithms
-1 8 from cryptography.hazmat.primitives.ciphers import modes
-1 9 from cryptography.hazmat.primitives.kdf.hkdf import HKDF
-1 10
-1 11 # https://www.ietf.org/rfc/rfc2409.html#section-6.2
-1 12 PRIME = int.from_bytes(
-1 13 b'\xff\xff\xff\xff\xff\xff\xff\xff\xc9\x0f\xda\xa2\x21\x68\xc2\x34'
-1 14 b'\xc4\xc6\x62\x8b\x80\xdc\x1c\xd1\x29\x02\x4e\x08\x8a\x67\xcc\x74'
-1 15 b'\x02\x0b\xbe\xa6\x3b\x13\x9b\x22\x51\x4a\x08\x79\x8e\x34\x04\xdd'
-1 16 b'\xef\x95\x19\xb3\xcd\x3a\x43\x1b\x30\x2b\x0a\x6d\xf2\x5f\x14\x37'
-1 17 b'\x4f\xe1\x35\x6d\x6d\x51\xc2\x45\xe4\x85\xb5\x76\x62\x5e\x7e\xc6'
-1 18 b'\xf4\x4c\x42\xe9\xa6\x37\xed\x6b\x0b\xff\x5c\xb6\xf4\x06\xb7\xed'
-1 19 b'\xee\x38\x6b\xfb\x5a\x89\x9f\xa5\xae\x9f\x24\x11\x7c\x4b\x1f\xe6'
-1 20 b'\x49\x28\x66\x51\xec\xe6\x53\x81\xff\xff\xff\xff\xff\xff\xff\xff'
-1 21 )
-1 22
-1 23
-1 24 class PlainSession:
-1 25 @classmethod
-1 26 def create(cls, input):
-1 27 return b'', cls()
-1 28
-1 29 def encode(self, path, msg):
-1 30 return (path, b'', msg, 'text/plain')
-1 31
-1 32 def decode(self, secret):
-1 33 return secret[2]
-1 34
-1 35
-1 36 class DHSession:
-1 37 # https://specifications.freedesktop.org/secret-service/latest/ch07s03.html
-1 38
-1 39 @classmethod
-1 40 def bytes_to_key(cls, b, parameters):
-1 41 return dh.DHPublicNumbers(
-1 42 int.from_bytes(b),
-1 43 parameters.parameter_numbers(),
-1 44 ).public_key()
-1 45
-1 46 @classmethod
-1 47 def key_to_bytes(cls, key):
-1 48 i = key.public_numbers().y
-1 49 return i.to_bytes(length=(i.bit_length() + 7) // 8)
-1 50
-1 51 @classmethod
-1 52 def get_key(cls, peer_bytes):
-1 53 parameters = dh.DHParameterNumbers(p=PRIME, g=2).parameters()
-1 54 server_private_key = parameters.generate_private_key()
-1 55 peer_public_key = cls.bytes_to_key(bytes(peer_bytes), parameters)
-1 56 shared_key = server_private_key.exchange(peer_public_key)
-1 57 derived_key = HKDF(
-1 58 algorithm=hashes.SHA256(),
-1 59 length=128 // 8,
-1 60 salt=None,
-1 61 info=b'',
-1 62 ).derive(shared_key)
-1 63 server_public_key = server_private_key.public_key()
-1 64 server_bytes = cls.key_to_bytes(server_public_key)
-1 65 return server_bytes, derived_key
-1 66
-1 67 @classmethod
-1 68 def create(cls, input):
-1 69 output, key = cls.get_key(input)
-1 70 return output, cls(key)
-1 71
-1 72 def __init__(self, key):
-1 73 self.key = key
-1 74
-1 75 def encode(self, path, msg):
-1 76 iv = os.urandom(16)
-1 77 padder = padding.PKCS7(128).padder()
-1 78 padded_data = padder.update(msg) + padder.finalize()
-1 79 cipher = Cipher(algorithms.AES128(self.key), modes.CBC(iv))
-1 80 encryptor = cipher.encryptor()
-1 81 ct = encryptor.update(padded_data) + encryptor.finalize()
-1 82 return (path, iv, ct, 'text/plain')
-1 83
-1 84 def decode(self, secret):
-1 85 cipher = Cipher(algorithms.AES128(self.key), modes.CBC(bytes(secret[1])))
-1 86 decryptor = cipher.decryptor()
-1 87 padded_data = decryptor.update(bytes(secret[2])) + decryptor.finalize()
-1 88 unpadder = padding.PKCS7(128).unpadder()
-1 89 return unpadder.update(padded_data) + unpadder.finalize()
-1 90
-1 91
-1 92 def create_session(algorithm: str, input: bytes) -> tuple[bytes, ...]:
-1 93 if algorithm == 'plain':
-1 94 return PlainSession.create(input)
-1 95 elif algorithm == 'dh-ietf1024-sha256-aes128-cbc-pkcs7':
-1 96 return DHSession.create(input)
-1 97 else:
-1 98 raise ValueError('unknown session algorithm')
diff --git a/xikeyring/keyring.py b/xikeyring/keyring.py
@@ -0,0 +1,219 @@
-1 1 import base64
-1 2 import json
-1 3 import os
-1 4 import sqlite3
-1 5
-1 6 from cryptography.fernet import Fernet
-1 7 from cryptography.hazmat.primitives import hashes
-1 8 from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
-1 9
-1 10 from .prompt import PinentryPrompt as Prompt
-1 11
-1 12
-1 13 class AccessDeniedError(Exception):
-1 14 pass
-1 15
-1 16
-1 17 class NotFoundError(Exception):
-1 18 pass
-1 19
-1 20
-1 21 class Crypt:
-1 22 def __init__(self, password: bytes):
-1 23 self.password = password
-1 24
-1 25 def get_key(self, salt: bytes, iterations: int) -> bytes:
-1 26 if iterations < 100_000:
-1 27 raise ValueError('Too few iterations')
-1 28 kdf = PBKDF2HMAC(
-1 29 algorithm=hashes.SHA256(),
-1 30 length=32,
-1 31 salt=salt,
-1 32 iterations=iterations,
-1 33 )
-1 34 return base64.urlsafe_b64encode(kdf.derive(self.password))
-1 35
-1 36 def encode(self, salt: bytes, iterations: int, content: bytes) -> bytes:
-1 37 return b'$'.join(
-1 38 [
-1 39 b'fernet',
-1 40 base64.urlsafe_b64encode(salt),
-1 41 str(iterations).encode(),
-1 42 content,
-1 43 ]
-1 44 )
-1 45
-1 46 def decode(self, data: bytes) -> tuple[bytes, int, bytes]:
-1 47 algo, salt, iterations, content = data.split(b'$')
-1 48 if algo != b'fernet':
-1 49 raise TypeError('Unknown encryption algorithm')
-1 50 return (
-1 51 base64.urlsafe_b64decode(salt),
-1 52 int(iterations, 10),
-1 53 content,
-1 54 )
-1 55
-1 56 def encrypt(self, data: bytes, iterations=100_000) -> bytes:
-1 57 salt = os.urandom(16)
-1 58 key = self.get_key(salt, iterations)
-1 59 content = Fernet(key).encrypt(data)
-1 60 return self.encode(salt, iterations, content)
-1 61
-1 62 def decrypt(self, data: bytes) -> bytes:
-1 63 salt, iterations, content = self.decode(data)
-1 64 key = self.get_key(salt, iterations)
-1 65 return Fernet(key).decrypt(content)
-1 66
-1 67
-1 68 class Keyring:
-1 69 _crypt: Crypt | None
-1 70
-1 71 def __init__(self, path: str):
-1 72 self._crypt = None
-1 73 self.db = sqlite3.connect(path)
-1 74 self.prompt = Prompt()
-1 75
-1 76 with self.db:
-1 77 self.db.execute(
-1 78 'CREATE TABLE IF NOT EXISTS items('
-1 79 'id INTEGER PRIMARY KEY, attributes JSON, secret BLOB)'
-1 80 )
-1 81 self.db.execute(
-1 82 'CREATE TABLE IF NOT EXISTS meta(id INTEGER PRIMARY KEY, value BLOB)'
-1 83 )
-1 84
-1 85 def close(self):
-1 86 self._crypt = None
-1 87 self.db.close()
-1 88
-1 89 def __enter__(self):
-1 90 return self
-1 91
-1 92 def __exit__(self, type, value, traceback):
-1 93 self.close()
-1 94
-1 95 def confirm_access(self) -> None:
-1 96 if not self.prompt.confirm('Allow access to secret from keyring?'):
-1 97 raise AccessDeniedError
-1 98
-1 99 def confirm_change(self) -> None:
-1 100 if not self.prompt.confirm('Allow changes to keyring?'):
-1 101 raise AccessDeniedError
-1 102
-1 103 @property
-1 104 def crypt(self) -> Crypt:
-1 105 while not self._crypt:
-1 106 password = self.prompt.get_password(
-1 107 'An application wants access to your keyring, but it is locked'
-1 108 )
-1 109 if not password:
-1 110 raise AccessDeniedError
-1 111 self._crypt = Crypt(password)
-1 112 try:
-1 113 self._validate_password()
-1 114 except ValueError:
-1 115 self._crypt = None
-1 116 return self._crypt
-1 117
-1 118 def _validate_password(self) -> None:
-1 119 # SECURITY: we use the same mechanism to derive encryption keys.
-1 120 # Is this secure?
-1 121 res = self.db.execute('SELECT value FROM meta WHERE id=1')
-1 122 row = res.fetchone()
-1 123 if row:
-1 124 salt, iterations, content = self.crypt.decode(row[0])
-1 125 if self.crypt.get_key(salt, iterations) != content:
-1 126 raise ValueError('incorect password')
-1 127 else:
-1 128 iterations = 480_000
-1 129 salt = os.urandom(32)
-1 130 key = self.crypt.get_key(salt, iterations)
-1 131 data = self.crypt.encode(salt, iterations, key)
-1 132 with self.db:
-1 133 self.db.execute('INSERT INTO meta(id, value) VALUES (1, ?)', [data])
-1 134
-1 135 def validate_password(self):
-1 136 # accessing the crypt will make sure that the password is validated
-1 137 # FIXME: not nice
-1 138 return self.crypt
-1 139
-1 140 def lock(self) -> None:
-1 141 if not self._crypt:
-1 142 raise ValueError
-1 143 self._crypt = None
-1 144
-1 145 def list_items(self) -> list[int]:
-1 146 res = self.db.execute('SELECT id FROM items')
-1 147 return [row[0] for row in res.fetchall()]
-1 148
-1 149 def search_items(self, query: dict[str, str]) -> list[int]:
-1 150 if not query:
-1 151 return self.list_items()
-1 152 params = []
-1 153 for key, value in query.items():
-1 154 params.append(f'$.{key}')
-1 155 params.append(value)
-1 156 sql = 'SELECT id FROM items WHERE ' + ' AND '.join(
-1 157 ['json_extract(attributes, ?) = ?' for _ in query]
-1 158 )
-1 159 res = self.db.execute(sql, params)
-1 160 return [row[0] for row in res.fetchall()]
-1 161
-1 162 def get_attributes(self, id: int) -> dict[str, str]:
-1 163 res = self.db.execute('SELECT attributes FROM items WHERE id = ?', [id])
-1 164 row = res.fetchone()
-1 165 if not row:
-1 166 raise NotFoundError
-1 167 return json.loads(row[0])
-1 168
-1 169 def get_secret(self, id: int) -> bytes:
-1 170 self.confirm_access()
-1 171 res = self.db.execute('SELECT secret FROM items WHERE id = ?', [id])
-1 172 row = res.fetchone()
-1 173 if not row:
-1 174 raise NotFoundError
-1 175 return self.crypt.decrypt(row[0])
-1 176
-1 177 def create_item(self, attributes: dict[str, str], secret: bytes):
-1 178 self.validate_password()
-1 179 with self.db:
-1 180 cur = self.db.cursor()
-1 181 cur.execute(
-1 182 'INSERT INTO items(attributes, secret) VALUES (json(?), ?)',
-1 183 [
-1 184 json.dumps(attributes),
-1 185 self.crypt.encrypt(secret),
-1 186 ],
-1 187 )
-1 188 return cur.lastrowid
-1 189
-1 190 def update_attributes(self, id: int, attributes: dict[str, str]) -> None:
-1 191 self.confirm_change()
-1 192 self.validate_password()
-1 193 with self.db:
-1 194 self.db.execute(
-1 195 'UPDATE items SET attributes=json(?) WHERE id=?',
-1 196 [json.dumps(attributes), id],
-1 197 )
-1 198
-1 199 def update_secret(self, id: int, secret: bytes) -> None:
-1 200 self.confirm_change()
-1 201 self.validate_password()
-1 202 with self.db:
-1 203 self.db.execute(
-1 204 'UPDATE items SET secret=? WHERE id=?',
-1 205 [self.crypt.encrypt(secret), id],
-1 206 )
-1 207
-1 208 def delete_item(self, id: int) -> None:
-1 209 self.confirm_change()
-1 210 self.validate_password()
-1 211 with self.db:
-1 212 self.db.execute('DELETE FROM items WHERE id=?', [id])
-1 213
-1 214
-1 215 if __name__ == '__main__':
-1 216 with Keyring('keyring.db') as k:
-1 217 id = k.create_item({'foo': 'bar'}, b'password')
-1 218 print(k.get_secret(id))
-1 219 k.delete_item(id)
diff --git a/xikeyring/org.freedesktop.Secrets.xml b/xikeyring/org.freedesktop.Secrets.xml
@@ -0,0 +1,146 @@
-1 1 <!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN"
-1 2 "http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
-1 3
-1 4 <node name="/org/freedesktop/Secrets">
-1 5 <interface name="org.freedesktop.Secret.Service">
-1 6 <property name="Collections" type="ao" access="read" />
-1 7
-1 8 <method name="OpenSession">
-1 9 <arg name="algorithm" type="s" direction="in"/>
-1 10 <arg name="input" type="v" direction="in"/>
-1 11 <arg name="output" type="v" direction="out"/>
-1 12 <arg name="result" type="o" direction="out"/>
-1 13 </method>
-1 14
-1 15 <method name="CreateCollection">
-1 16 <arg name="properties" type="a{sv}" direction="in"/>
-1 17 <arg name="alias" type="s" direction="in"/>
-1 18 <arg name="collection" type="o" direction="out"/>
-1 19 <arg name="prompt" type="o" direction="out"/>
-1 20 </method>
-1 21
-1 22 <method name="SearchItems">
-1 23 <arg name="attributes" type="a{ss}" direction="in"/>
-1 24 <arg name="unlocked" type="ao" direction="out"/>
-1 25 <arg name="locked" type="ao" direction="out"/>
-1 26 </method>
-1 27
-1 28 <method name="Unlock">
-1 29 <arg name="objects" type="ao" direction="in"/>
-1 30 <arg name="unlocked" type="ao" direction="out"/>
-1 31 <arg name="prompt" type="o" direction="out"/>
-1 32 </method>
-1 33
-1 34 <method name="Lock">
-1 35 <arg name="objects" type="ao" direction="in"/>
-1 36 <arg name="locked" type="ao" direction="out"/>
-1 37 <arg name="Prompt" type="o" direction="out"/>
-1 38 </method>
-1 39
-1 40 <method name="GetSecrets">
-1 41 <arg name="items" type="ao" direction="in"/>
-1 42 <arg name="session" type="o" direction="in"/>
-1 43 <arg name="secrets" type="a{o(oayays)}" direction="out"/>
-1 44 </method>
-1 45
-1 46 <method name="ReadAlias">
-1 47 <arg name="name" type='s' direction='in'/>
-1 48 <arg name="collection" type='o' direction='out'/>
-1 49 </method>
-1 50
-1 51 <method name="SetAlias">
-1 52 <arg name="name" type='s' direction='in'/>
-1 53 <arg name="collection" type='o' direction='in'/>
-1 54 </method>
-1 55
-1 56 <signal name="CollectionCreated">
-1 57 <arg name="collection" type="o"/>
-1 58 </signal>
-1 59
-1 60 <signal name="CollectionDeleted">
-1 61 <arg name="collection" type="o"/>
-1 62 </signal>
-1 63
-1 64 <signal name="CollectionChanged">
-1 65 <arg name="collection" type="o"/>
-1 66 </signal>
-1 67 </interface>
-1 68
-1 69 <interface name="org.freedesktop.Secret.Collection">
-1 70 <property name="Items" type="ao" access="read"/>
-1 71 <property name="Label" type="s" access="readwrite"/>
-1 72 <property name="Locked" type="b" access="read"/>
-1 73 <property name="Created" type="t" access="read"/>
-1 74 <property name="Modified" type="t" access="read"/>
-1 75
-1 76 <method name="Delete">
-1 77 <arg name="prompt" type="o" direction="out"/>
-1 78 </method>
-1 79
-1 80 <method name="SearchItems">
-1 81 <arg name="attributes" type="a{ss}" direction="in"/>
-1 82 <arg name="results" type="ao" direction="out"/>
-1 83 </method>
-1 84
-1 85 <method name="CreateItem">
-1 86 <arg name="properties" type="a{sv}" direction="in"/>
-1 87 <arg name="secret" type="(oayays)" direction="in"/>
-1 88 <arg name="replace" type="b" direction="in"/>
-1 89 <arg name="item" type="o" direction="out"/>
-1 90 <arg name="prompt" type="o" direction="out"/>
-1 91 </method>
-1 92
-1 93 <signal name="ItemCreated">
-1 94 <arg name="item" type="o"/>
-1 95 </signal>
-1 96
-1 97 <signal name="ItemDeleted">
-1 98 <arg name="item" type="o"/>
-1 99 </signal>
-1 100
-1 101 <signal name="ItemChanged">
-1 102 <arg name="item" type="o"/>
-1 103 </signal>
-1 104 </interface>
-1 105
-1 106 <interface name="org.freedesktop.Secret.Item">
-1 107 <property name="Locked" type="b" access="read"/>
-1 108 <property name="Attributes" type="a{ss}" access="readwrite"/>
-1 109 <property name="Label" type="s" access="readwrite"/>
-1 110 <property name="Type" type="s" access="readwrite"/>
-1 111 <property name="Created" type="t" access="read"/>
-1 112 <property name="Modified" type="t" access="read"/>
-1 113
-1 114 <method name="Delete">
-1 115 <arg name="Prompt" type="o" direction="out"/>
-1 116 </method>
-1 117
-1 118 <method name="GetSecret">
-1 119 <arg name="session" type="o" direction="in"/>
-1 120 <arg name="secret" type="(oayays)" direction="out"/>
-1 121 </method>
-1 122
-1 123 <method name="SetSecret">
-1 124 <arg name="secret" type="(oayays)" direction="in"/>
-1 125 </method>
-1 126 </interface>
-1 127
-1 128 <interface name="org.freedesktop.Secret.Session">
-1 129 <method name="Close">
-1 130 </method>
-1 131 </interface>
-1 132
-1 133 <interface name="org.freedesktop.Secret.Prompt">
-1 134 <method name="Prompt">
-1 135 <arg name="window_id" type="s" direction="in"/>
-1 136 </method>
-1 137
-1 138 <method name="Dismiss">
-1 139 </method>
-1 140
-1 141 <signal name="Completed">
-1 142 <arg name="dismissed" type="b"/>
-1 143 <arg name="result" type="v"/>
-1 144 </signal>
-1 145 </interface>
-1 146 </node>
diff --git a/xikeyring/prompt.py b/xikeyring/prompt.py
@@ -0,0 +1,80 @@
-1 1 import os
-1 2 import subprocess
-1 3 import sys
-1 4
-1 5
-1 6 class PinentryPrompt:
-1 7 def __enter__(self):
-1 8 self._proc = subprocess.Popen(
-1 9 ['pinentry'],
-1 10 stdin=subprocess.PIPE,
-1 11 stdout=subprocess.PIPE,
-1 12 stderr=subprocess.PIPE,
-1 13 )
-1 14 resp = self._proc.stdout.readline()
-1 15 assert resp.startswith(b'OK')
-1 16
-1 17 if sys.stdout.isatty():
-1 18 ttyname = os.ttyname(sys.stdout.fileno())
-1 19 self.call(b'OPTION ttyname=' + ttyname.encode())
-1 20
-1 21 return self
-1 22
-1 23 def __exit__(self, *args):
-1 24 self._proc.terminate()
-1 25
-1 26 def encode(self, s: str) -> bytes:
-1 27 result = ''
-1 28 for c in s:
-1 29 if ord(c) < 33:
-1 30 result += f'%{ord(c):02x}'
-1 31 else:
-1 32 result += c
-1 33 return result.encode()
-1 34
-1 35 def call(self, cmd: bytes) -> tuple[bool, list[bytes]]:
-1 36 self._proc.stdin.write(cmd + b'\n')
-1 37 self._proc.stdin.flush()
-1 38 resp = []
-1 39 for line in self._proc.stdout:
-1 40 resp.append(line)
-1 41 if line.startswith(b'OK'):
-1 42 return True, resp
-1 43 elif line.startswith(b'ERR'):
-1 44 return False, resp
-1 45 raise AssertionError('unreachable')
-1 46
-1 47 def setup(self, title: str, desc: str):
-1 48 self.call(b'SETTITLE ' + self.encode(title))
-1 49 self.call(b'SETPROMPT ' + self.encode(title))
-1 50 self.call(b'SETDESC ' + self.encode(desc))
-1 51 self.call(b'SETQUALITYBAR')
-1 52
-1 53 def get_password(self, desc: str) -> bytes | None:
-1 54 with self:
-1 55 self.setup('Authentication required', desc)
-1 56 success, resp = self.call(b'GETPIN')
-1 57 if success:
-1 58 for line in resp:
-1 59 if line.startswith(b'D '):
-1 60 return line[2:-1]
-1 61 return None
-1 62
-1 63 def confirm(self, desc: str) -> bool:
-1 64 with self:
-1 65 self.setup('Confirmation required', desc)
-1 66 success, resp = self.call(b'CONFIRM')
-1 67 return success
-1 68
-1 69
-1 70 class DummyPrompt:
-1 71 def get_password(self, desc):
-1 72 return b'password'
-1 73
-1 74 def confirm(self, desc):
-1 75 return True
-1 76
-1 77
-1 78 if __name__ == '__main__':
-1 79 prompt = PinentryPrompt()
-1 80 print(prompt.get_password('please enter a password'))