Konubinix' opinionated web of thoughts

How to Play With a Desktop Password Manager

Fleeting

Many workflows need a desktop password manager: pass integration, browser auto-fill, SSH agent unlocking, etc. Testing or developing such workflows usually means running gnome-keyring or KeePassXC — tools that are heavy, intrusive (they hijack the D-Bus bus name on login, spawn agents, pop up unlock dialogs) and hard to reset to a clean state.

The freedesktop.org Secret Service API defines a D-Bus interface that desktop password managers implement. Applications like browsers, email clients and CLI tools use this API to store and retrieve secrets without knowing which password manager is running.

To understand how this works — and to get a lightweight harness for testing — we build a toy mock provider (the password manager itself), not a client. We register on the session bus as org.freedesktop.secrets and implement just enough of the spec so that standard tools like secret-tool and Seahorse can talk to us. The result is a single-file, zero-config script that:

  • starts instantly, with no daemon or unlock prompt,
  • stores secrets in plain JSON (easy to inspect, reset or version-control),
  • can be killed and restarted at will without side effects,
  • does not interfere with a real password manager (just stop it and restart the real one).

This makes it ideal for integration tests, scripted demos, or simply learning how the Secret Service protocol works without the lourdeur of a production-grade tool.

Secrets are stored in plain JSON on disk. This is a toy for learning and testing, not for real use.

Tangle block

This block aggregates all the code and tangles into the script.

Dependencies

We use nix-shell in the shebang to pull in all dependencies automatically: dbus-python for the D-Bus bindings, pygobject3 for the GLib main loop, and cryptography for AES and HKDF. This means the script is entirely self-contained — no virtualenv, no pip install, just chmod +x and run.

import hashlib
import json
import os
import secrets
import signal
import sys
import uuid

import dbus
import dbus.service
import dbus.mainloop.glib
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.hashes import SHA256
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives.padding import PKCS7
from gi.repository import GLib

Constants

The Secret Service spec defines a set of well-known D-Bus names, object paths and interfaces. Our service claims org.freedesktop.secrets on the session bus and exposes a single collection at /org/freedesktop/secrets/collection/default.

The DH prime is the RFC 2409 group 2 (1024-bit MODP), which is what the dh-ietf1024-sha256-aes128-cbc-pkcs7 algorithm uses.

# DH parameters for dh-ietf1024-sha256-aes128-cbc-pkcs7 (RFC 2409 group 2)
DH_PRIME = int(
    "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1"
    "29024E088A67CC74020BBEA63B139B22514A08798E3404DD"
    "EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245"
    "E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED"
    "EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE65381"
    "FFFFFFFFFFFFFFFF",
    16,
)
DH_GENERATOR = 2


# Where we persist secrets (plain JSON — this is a toy!)
DEFAULT_DB = os.path.expanduser("~/.local/share/mock-secret-service.json")

SS_BUS_NAME = "org.freedesktop.secrets"
SS_PATH = "/org/freedesktop/secrets"
COLLECTION_PATH = "/org/freedesktop/secrets/collection/default"
ALIAS_PATH = "/org/freedesktop/secrets/aliases/default"

SERVICE_IFACE = "org.freedesktop.Secret.Service"
COLLECTION_IFACE = "org.freedesktop.Secret.Collection"
ITEM_IFACE = "org.freedesktop.Secret.Item"
SESSION_IFACE = "org.freedesktop.Secret.Session"
PROMPT_IFACE = "org.freedesktop.Secret.Prompt"
PROPERTIES_IFACE = "org.freedesktop.DBus.Properties"

JSON-backed storage

SecretDB is a simple persistence layer. Each item has a label, a dict of attributes (used for searching) and the secret text. Items are identified by random 12-character hex IDs.

Real password managers encrypt the storage at rest. We deliberately skip that: the whole point of a mock is to make the stored state trivially inspectable. You can cat the JSON file to verify what a client actually wrote, jq it in a test assertion, or simply rm it to start fresh. None of this is practical with an encrypted keyring.

class SecretDB:
    """Simple JSON-backed secret store."""

    def __init__(self, path):
        self.path = path
        self.items = {}  # id -> {label, attributes, secret}
        self.load()

    def load(self):
        if os.path.exists(self.path):
            with open(self.path) as f:
                self.items = json.load(f)
            print(f"Loaded {len(self.items)} items from {self.path}")
        else:
            self.items = {}

    def save(self):
        os.makedirs(os.path.dirname(self.path), exist_ok=True)
        with open(self.path, "w") as f:
            json.dump(self.items, f, indent=2)

    def new_id(self):
        return str(uuid.uuid4().hex[:12])

    def create(self, label, attributes, secret_text, replace=False):
        if replace:
            for item_id, item in list(self.items.items()):
                if item["attributes"] == attributes:
                    item["label"] = label
                    item["secret"] = secret_text
                    self.save()
                    return item_id, False
        item_id = self.new_id()
        self.items[item_id] = {
            "label": label,
            "attributes": dict(attributes),
            "secret": secret_text,
        }
        self.save()
        return item_id, True

    def delete(self, item_id):
        if item_id in self.items:
            del self.items[item_id]
            self.save()
            return True
        return False

    def search(self, attributes):
        results = []
        for item_id, item in self.items.items():
            if all(item["attributes"].get(k) == v for k, v in attributes.items()):
                results.append(item_id)
        return results

DH encryption helpers

One might wonder: if we only want to store and retrieve secrets, why bother with encryption at all? The answer is that the DH encryption protects secrets in transit over D-Bus, not at rest. D-Bus is an IPC bus — other processes running under the same user can potentially snoop on messages. The spec therefore offers dh-ietf1024-sha256-aes128-cbc-pkcs7 as a transport encryption layer: after a Diffie-Hellman key exchange, both sides derive an AES-128 key and use it with CBC mode and PKCS7 padding.

More importantly, in practice clients like secret-tool and libsecret always request a DH session and never fall back to plain. So even though our mock doesn’t care about security, we must implement the full DH handshake or those standard tools will simply refuse to talk to us.

def dh_decrypt(aes_key, params_iv, ciphertext):
    """Decrypt a secret received from a client using the negotiated AES key."""
    cipher = Cipher(algorithms.AES(aes_key), modes.CBC(bytes(params_iv)))
    decryptor = cipher.decryptor()
    padded = decryptor.update(bytes(ciphertext)) + decryptor.finalize()
    unpadder = PKCS7(128).unpadder()
    return unpadder.update(padded) + unpadder.finalize()


def dh_encrypt(aes_key, plaintext):
    """Encrypt a secret to send to a client using the negotiated AES key."""
    iv = secrets.token_bytes(16)
    padder = PKCS7(128).padder()
    padded = padder.update(plaintext) + padder.finalize()
    cipher = Cipher(algorithms.AES(aes_key), modes.CBC(iv))
    encryptor = cipher.encryptor()
    ciphertext = encryptor.update(padded) + encryptor.finalize()
    return iv, ciphertext

Sessions

A session is the first thing a client establishes before it can store or retrieve any secret. The client calls OpenSession on the service and negotiates either plain (no encryption, secrets sent as-is) or dh-ietf1024-sha256-aes128-cbc-pkcs7 (encrypted transport).

Since secret-tool and libsecret always request DH, we must support the full handshake. MockSession wraps the negotiated AES key (or None for plain) and provides encrypt/decrypt helpers that every subsequent GetSecret / SetSecret / CreateItem call uses transparently.

class MockSession(dbus.service.Object):
    def __init__(self, bus, path, aes_key=None):
        self._path = path
        self.aes_key = aes_key  # None means plain, bytes means DH
        super().__init__(bus, path)

    def decrypt_secret(self, params_iv, ciphertext):
        if self.aes_key is None:
            return bytes(ciphertext)
        return dh_decrypt(self.aes_key, params_iv, ciphertext)

    def encrypt_secret(self, plaintext):
        if self.aes_key is None:
            return dbus.ByteArray(b""), dbus.ByteArray(plaintext)
        iv, ciphertext = dh_encrypt(self.aes_key, plaintext)
        return dbus.ByteArray(iv), dbus.ByteArray(ciphertext)

    @dbus.service.method(SESSION_IFACE, in_signature="", out_signature="")
    def Close(self):
        self.remove_from_connection()

Prompts

The spec includes a Prompt interface for operations that require user confirmation (like unlocking a collection or authorizing a delete). A real password manager would pop up a dialog here — exactly the kind of interactive friction we want to avoid in a test harness. Since our mock never locks anything and requires no user interaction, our prompts always complete immediately with success — but they print a notification to stdout so you can see exactly when and where a real password manager would have prompted the user. This is one of the main ergonomic wins over gnome-keyring: no modal dialogs blocking your scripts, while still giving visibility into the interaction flow.

class MockPrompt(dbus.service.Object):
    """No-op prompt — always completes immediately."""

    def __init__(self, bus, path):
        super().__init__(bus, path)

    @dbus.service.method(PROMPT_IFACE, in_signature="s", out_signature="")
    def Prompt(self, window_id):
        print(f"  [PROMPT] A real password manager would prompt the user here (window_id={window_id!r}) — auto-completing.")
        self.Completed(False, dbus.String("", variant_level=1))

    @dbus.service.method(PROMPT_IFACE, in_signature="", out_signature="")
    def Dismiss(self):
        print("  [PROMPT] Dismiss requested — auto-completing as dismissed.")
        self.Completed(True, dbus.String("", variant_level=1))

    @dbus.service.signal(PROMPT_IFACE, signature="bv")
    def Completed(self, dismissed, result):
        pass

Items

Each secret is represented as a D-Bus object implementing org.freedesktop.Secret.Item. This is the core of the spec: every stored credential (a password, an API token, an SSH passphrase…) is an item with a label, a set of key/value attributes (used for searching) and the secret value itself, accessed via GetSecret / SetSecret.

The Properties interface (Get, Set, GetAll) is how clients like Seahorse discover item metadata without fetching the secret itself.

When an item is deleted, we mark it rather than unregistering it from D-Bus immediately. This prevents errors when a client (like Seahorse) retries a delete on a cached reference.

class MockItem(dbus.service.Object):
    def __init__(self, bus, path, db, item_id, sessions, collection=None):
        self._path = path
        self._db = db
        self._item_id = item_id
        self._sessions = sessions
        self._collection = collection
        self._deleted = False
        super().__init__(bus, path)

    @dbus.service.method(PROPERTIES_IFACE, in_signature="ss", out_signature="v")
    def Get(self, interface, prop):
        item = self._db.items.get(self._item_id, {})
        if prop == "Label":
            return item.get("label", "")
        elif prop == "Attributes":
            return dbus.Dictionary(item.get("attributes", {}), signature="ss")
        elif prop == "Locked":
            return False
        elif prop == "Created":
            return dbus.UInt64(0)
        elif prop == "Modified":
            return dbus.UInt64(0)
        return ""

    @dbus.service.method(PROPERTIES_IFACE, in_signature="ssv", out_signature="")
    def Set(self, interface, prop, value):
        item = self._db.items.get(self._item_id)
        if not item:
            return
        if prop == "Label":
            item["label"] = str(value)
        elif prop == "Attributes":
            item["attributes"] = dict(value)
        self._db.save()

    @dbus.service.method(PROPERTIES_IFACE, in_signature="s", out_signature="a{sv}")
    def GetAll(self, interface):
        item = self._db.items.get(self._item_id, {})
        return {
            "Label": item.get("label", ""),
            "Attributes": dbus.Dictionary(item.get("attributes", {}), signature="ss"),
            "Locked": False,
            "Created": dbus.UInt64(0),
            "Modified": dbus.UInt64(0),
        }

    @dbus.service.method(ITEM_IFACE, in_signature="o", out_signature="(oayays)")
    def GetSecret(self, session):
        item = self._db.items.get(self._item_id, {})
        secret = item.get("secret", "").encode("utf-8")
        sess = self._sessions.get(str(session))
        if sess:
            params, value = sess.encrypt_secret(secret)
        else:
            params, value = dbus.ByteArray(b""), dbus.ByteArray(secret)
        return dbus.Struct(
            (dbus.ObjectPath(session), params, value, "text/plain"),
            signature="oayays",
        )

    @dbus.service.method(ITEM_IFACE, in_signature="(oayays)", out_signature="")
    def SetSecret(self, secret_struct):
        item = self._db.items.get(self._item_id)
        if item:
            sess = self._sessions.get(str(secret_struct[0]))
            if sess:
                plaintext = sess.decrypt_secret(secret_struct[1], secret_struct[2])
            else:
                plaintext = bytes(secret_struct[2])
            item["secret"] = plaintext.decode("utf-8")
            self._db.save()

    @dbus.service.method(ITEM_IFACE, in_signature="", out_signature="o")
    def Delete(self):
        print(f"  Deleting item {self._item_id}")
        item_path = self._path
        self._db.delete(self._item_id)
        self._deleted = True
        if self._collection:
            self._collection._items.pop(self._item_id, None)
            self._collection._emit_items_changed()
            self._collection.ItemDeleted(dbus.ObjectPath(item_path))
        return dbus.ObjectPath("/")

Collections

A collection groups items together — think of it as a keyring. Real password managers may have multiple keyrings (e.g. “Login”, “Personal”, “Work”), each potentially locked independently. Our mock keeps things simple with a single “Default” collection that is always unlocked. It handles CreateItem (decrypting the secret from the client session, storing it, and registering a new D-Bus item object) and SearchItems (matching by attributes).

Signals

Seahorse and other GUI clients need to know when items change. The spec defines ItemCreated, ItemDeleted, and ItemChanged signals on the Collection interface. But that’s not enough: Seahorse relies on the standard org.freedesktop.DBus.Properties.PropertiesChanged signal to update its item list. Without it, newly created items don’t appear and deleted ones linger.

class MockCollection(dbus.service.Object):
    """A collection that owns items. Items are always registered under this path."""

    def __init__(self, bus, path, db, sessions):
        self._bus = bus
        self._path = path
        self._db = db
        self._sessions = sessions
        self._items = {}  # item_id -> MockItem
        super().__init__(bus, path)
        # Register existing items
        for item_id in db.items:
            self._register_item(item_id)

    def _register_item(self, item_id):
        item_path = f"{self._path}/{item_id}"
        self._items[item_id] = MockItem(self._bus, item_path, self._db, item_id, self._sessions, self)
        return item_path

    def _emit_items_changed(self):
        items = dbus.Array(
            [dbus.ObjectPath(f"{self._path}/{iid}") for iid in self._db.items],
            signature="o",
        )
        self.PropertiesChanged(
            COLLECTION_IFACE,
            {"Items": items},
            [],
        )

    @dbus.service.method(PROPERTIES_IFACE, in_signature="ss", out_signature="v")
    def Get(self, interface, prop):
        if prop == "Items":
            return dbus.Array(
                [dbus.ObjectPath(f"{self._path}/{iid}") for iid in self._db.items],
                signature="o",
            )
        elif prop == "Label":
            return "Default"
        elif prop == "Locked":
            return False
        elif prop == "Created":
            return dbus.UInt64(0)
        elif prop == "Modified":
            return dbus.UInt64(0)
        return ""

    @dbus.service.method(PROPERTIES_IFACE, in_signature="ssv", out_signature="")
    def Set(self, interface, prop, value):
        pass

    @dbus.service.method(PROPERTIES_IFACE, in_signature="s", out_signature="a{sv}")
    def GetAll(self, interface):
        return {
            "Items": dbus.Array(
                [dbus.ObjectPath(f"{self._path}/{iid}") for iid in self._db.items],
                signature="o",
            ),
            "Label": "Default",
            "Locked": False,
        }

    @dbus.service.method(COLLECTION_IFACE, in_signature="a{sv}(oayays)b", out_signature="oo")
    def CreateItem(self, properties, secret_struct, replace):
        label = str(properties.get(f"{ITEM_IFACE}.Label", ""))
        attributes = dict(properties.get(f"{ITEM_IFACE}.Attributes", {}))
        sess = self._sessions.get(str(secret_struct[0]))
        if sess:
            plaintext = sess.decrypt_secret(secret_struct[1], secret_struct[2])
        else:
            plaintext = bytes(secret_struct[2])
        secret_text = plaintext.decode("utf-8")

        item_id, is_new = self._db.create(label, attributes, secret_text, replace)
        print(f"  {'Created' if is_new else 'Updated'}: {label} ({item_id})")

        if item_id not in self._items:
            self._register_item(item_id)

        item_path = f"{self._path}/{item_id}"
        self._emit_items_changed()
        self.ItemCreated(dbus.ObjectPath(item_path))
        return dbus.ObjectPath(item_path), dbus.ObjectPath("/")

    @dbus.service.method(COLLECTION_IFACE, in_signature="a{ss}", out_signature="ao")
    def SearchItems(self, attributes):
        ids = self._db.search(dict(attributes))
        return dbus.Array(
            [dbus.ObjectPath(f"{self._path}/{iid}") for iid in ids],
            signature="o",
        )

    @dbus.service.signal(COLLECTION_IFACE, signature="o")
    def ItemCreated(self, item_path):
        pass

    @dbus.service.signal(COLLECTION_IFACE, signature="o")
    def ItemDeleted(self, item_path):
        pass

    @dbus.service.signal(COLLECTION_IFACE, signature="o")
    def ItemChanged(self, item_path):
        pass

    @dbus.service.signal(PROPERTIES_IFACE, signature="sa{sv}as")
    def PropertiesChanged(self, interface, changed, invalidated):
        pass

    @dbus.service.method(COLLECTION_IFACE, in_signature="", out_signature="o")
    def Delete(self):
        return dbus.ObjectPath("/")

Alias delegation

The spec defines aliases like default that point to a collection. Clients may access items via /org/freedesktop/secrets/aliases/default instead of the real path /org/freedesktop/secrets/collection/default.

We need a thin D-Bus wrapper at the alias path that delegates every call to the real collection. Without this, items created through the alias path would be registered under a different D-Bus path than where SearchItems looks for them.

class AliasCollection(dbus.service.Object):
    """Thin wrapper at the alias path that delegates to the real collection."""

    def __init__(self, bus, path, real_collection):
        self._real = real_collection
        super().__init__(bus, path)

    @dbus.service.method(PROPERTIES_IFACE, in_signature="ss", out_signature="v")
    def Get(self, interface, prop):
        return self._real.Get(interface, prop)

    @dbus.service.method(PROPERTIES_IFACE, in_signature="ssv", out_signature="")
    def Set(self, interface, prop, value):
        self._real.Set(interface, prop, value)

    @dbus.service.method(PROPERTIES_IFACE, in_signature="s", out_signature="a{sv}")
    def GetAll(self, interface):
        return self._real.GetAll(interface)

    @dbus.service.method(COLLECTION_IFACE, in_signature="a{sv}(oayays)b", out_signature="oo")
    def CreateItem(self, properties, secret_struct, replace):
        return self._real.CreateItem(properties, secret_struct, replace)

    @dbus.service.method(COLLECTION_IFACE, in_signature="a{ss}", out_signature="ao")
    def SearchItems(self, attributes):
        return self._real.SearchItems(attributes)

    @dbus.service.method(COLLECTION_IFACE, in_signature="", out_signature="o")
    def Delete(self):
        return dbus.ObjectPath("/")

The service

MockSecretService is the top-level D-Bus object at /org/freedesktop/secrets — the entry point that clients discover first. It wires everything together: it creates the default collection, manages sessions, and dispatches the high-level operations that clients use. It handles:

  • OpenSession: establishes a session with the client. For DH sessions, we do a full Diffie-Hellman key exchange and derive the AES key.
  • SearchItems: searches across all collections (we only have one).
  • Unlock/Lock: no-ops since we never lock.
  • GetSecrets: bulk secret retrieval.
  • ReadAlias: resolves alias names to collection paths.

DH key exchange details

The client sends its public key as big-endian bytes. We generate our own keypair, compute the shared secret, and derive a 16-byte AES key using HKDF-SHA256 with no salt and no info.

Two subtleties discovered by testing against libsecret:

  • The shared secret must be encoded as unsigned big-endian with no leading zero padding (not padded to 128 bytes).
  • Key derivation must use HKDF (not plain SHA-256), otherwise decryption fails with “invalid padding bytes”.

class MockSecretService(dbus.service.Object):
    def __init__(self, bus, db):
        self._bus = bus
        self._db = db
        self._sessions = {}
        self._session_counter = 0
        super().__init__(bus, SS_PATH)

        # Create default collection, items live under COLLECTION_PATH
        self._collection = MockCollection(bus, COLLECTION_PATH, db, self._sessions)
        # Alias delegates to the real collection
        self._alias_collection = AliasCollection(bus, ALIAS_PATH, self._collection)

    @dbus.service.method(SERVICE_IFACE, in_signature="sv", out_signature="vo")
    def OpenSession(self, algorithm, input_param):
        self._session_counter += 1
        session_path = f"{SS_PATH}/session/s{self._session_counter}"

        if algorithm == "plain":
            session = MockSession(self._bus, session_path, aes_key=None)
            self._sessions[session_path] = session
            print(f"  Session opened: {session_path} (plain)")
            return dbus.String("", variant_level=1), dbus.ObjectPath(session_path)

        elif algorithm == "dh-ietf1024-sha256-aes128-cbc-pkcs7":
            # Client sends its DH public key as big-endian bytes
            client_pubkey_bytes = bytes(input_param)
            client_pubkey = int.from_bytes(client_pubkey_bytes, "big")

            # Generate our DH keypair
            server_privkey = secrets.randbelow(DH_PRIME - 2) + 1
            server_pubkey = pow(DH_GENERATOR, server_privkey, DH_PRIME)

            # Compute shared secret
            shared = pow(client_pubkey, server_privkey, DH_PRIME)
            # libsecret uses unsigned big-endian with no leading zero padding
            shared_bytes = shared.to_bytes((shared.bit_length() + 7) // 8, "big")

            # Derive AES key via HKDF-SHA256 (no salt, no info), 16 bytes output
            aes_key = HKDF(
                algorithm=SHA256(), length=16, salt=None, info=None,
            ).derive(shared_bytes)

            session = MockSession(self._bus, session_path, aes_key=aes_key)
            self._sessions[session_path] = session

            # Return our public key as big-endian bytes
            server_pubkey_bytes = server_pubkey.to_bytes(128, "big")
            print(f"  Session opened: {session_path} (DH encrypted)")
            return dbus.ByteArray(server_pubkey_bytes, variant_level=1), dbus.ObjectPath(session_path)

        else:
            raise dbus.exceptions.DBusException(
                "org.freedesktop.DBus.Error.NotSupported",
                f"Algorithm {algorithm} not supported",
            )

    @dbus.service.method(SERVICE_IFACE, in_signature="a{ss}", out_signature="aoao")
    def SearchItems(self, attributes):
        ids = self._db.search(dict(attributes))
        paths = dbus.Array(
            [dbus.ObjectPath(f"{COLLECTION_PATH}/{iid}") for iid in ids],
            signature="o",
        )
        print(f"  SearchItems({dict(attributes)}) -> {len(ids)} results")
        return paths, dbus.Array([], signature="o")  # all unlocked

    @dbus.service.method(SERVICE_IFACE, in_signature="ao", out_signature="aoo")
    def Unlock(self, objects):
        # Everything is always unlocked in our mock
        return dbus.Array(objects, signature="o"), dbus.ObjectPath("/")

    @dbus.service.method(SERVICE_IFACE, in_signature="ao", out_signature="aoo")
    def Lock(self, objects):
        return dbus.Array([], signature="o"), dbus.ObjectPath("/")

    @dbus.service.method(SERVICE_IFACE, in_signature="aoo", out_signature="a{o(oayays)}")
    def GetSecrets(self, items, session):
        sess = self._sessions.get(str(session))
        result = {}
        for item_path in items:
            item_id = str(item_path).split("/")[-1]
            item = self._db.items.get(item_id)
            if item:
                plaintext = item["secret"].encode("utf-8")
                if sess:
                    params, value = sess.encrypt_secret(plaintext)
                else:
                    params, value = dbus.ByteArray(b""), dbus.ByteArray(plaintext)
                result[item_path] = dbus.Struct(
                    (dbus.ObjectPath(session), params, value, "text/plain"),
                    signature="oayays",
                )
        return dbus.Dictionary(result, signature="o(oayays)")

    @dbus.service.method(SERVICE_IFACE, in_signature="a{sv}s", out_signature="oo")
    def CreateCollection(self, properties, alias):
        # We only support the default collection
        return dbus.ObjectPath(COLLECTION_PATH), dbus.ObjectPath("/")

    @dbus.service.method(PROPERTIES_IFACE, in_signature="ss", out_signature="v")
    def Get(self, interface, prop):
        if prop == "Collections":
            return dbus.Array([dbus.ObjectPath(COLLECTION_PATH)], signature="o")
        return ""

    @dbus.service.method(PROPERTIES_IFACE, in_signature="ssv", out_signature="")
    def Set(self, interface, prop, value):
        pass

    @dbus.service.method(PROPERTIES_IFACE, in_signature="s", out_signature="a{sv}")
    def GetAll(self, interface):
        return {
            "Collections": dbus.Array(
                [dbus.ObjectPath(COLLECTION_PATH)], signature="o"
            ),
        }

    @dbus.service.method(SERVICE_IFACE, in_signature="s", out_signature="o")
    def ReadAlias(self, name):
        if str(name) == "default":
            return dbus.ObjectPath(COLLECTION_PATH)
        return dbus.ObjectPath("/")

    @dbus.service.method(SERVICE_IFACE, in_signature="so", out_signature="")
    def SetAlias(self, name, collection):
        pass

Main entry point

We initialize the GLib main loop (required by dbus-python), claim the bus name org.freedesktop.secrets, and run until interrupted. The do_not_queue=True flag means we fail immediately if another service already owns the name — you’ll get a clear error instead of silently waiting behind gnome-keyring.

def main():
    db_path = DEFAULT_DB
    if "--db" in sys.argv:
        idx = sys.argv.index("--db")
        db_path = sys.argv[idx + 1]

    dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
    bus = dbus.SessionBus()

    # Claim the bus name
    bus_name = dbus.service.BusName(SS_BUS_NAME, bus, do_not_queue=True)

    db = SecretDB(db_path)
    service = MockSecretService(bus, db)

    print(f"Mock Secret Service running on D-Bus as {SS_BUS_NAME}")
    print(f"Database: {db_path}")
    print(f"Items: {len(db.items)}")
    print()
    print("Try from another terminal:")
    print('  secret-tool store --label "test" service mock username alice')
    print("  secret-tool lookup service mock username alice")
    print("  secret-tool search service mock")
    print()

    loop = GLib.MainLoop()
    signal.signal(signal.SIGINT, lambda *_: loop.quit())
    signal.signal(signal.SIGTERM, lambda *_: loop.quit())
    loop.run()


if __name__ == "__main__":
    main()

Usage

Start the mock service:

mock_password_manager.py

Store a secret:

secret-tool store --label "test" service mock username alice

Look it up:

secret-tool lookup service mock username alice

Search:

secret-tool search service mock

You can also use Seahorse (GNOME Passwords & Keys) to browse, create and delete secrets in the “Default” keyring.