xi-keyring

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

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'))