Skip to content

kernel_keyring

kernel_keyring

Linux kernel-keyring binding for the volatile passphrase cache tier.

A thin, dependency-free ctypes wrapper over libkeyutils.so.1 — the userspace shim for the kernel key-retention service (add_key(2) / keyctl(2)). It exposes exactly the four operations the vault's volatile passphrase cache needs, and nothing else:

Why these exact choices (see the prior-art survey on the tier — the kernel keyring is how systemd-ask-password and MIT Kerberos cache passphrases):

  • Key type user, not logon. The passphrase must be read back to open SQLCipher; logon payloads are unreadable from userspace by anyone, for any permission mask. user is the only workable type (the same choice cryptsetup's readback path and eCryptfs are forced into).
  • Anchor @u (the user keyring), not the persistent keyring. The file this tier replaces lived under $XDG_RUNTIME_DIR, which logind wipes on final logout — so its effective lifetime already was the user keyring's lifetime (per-uid, shared across every same-uid terminal, torn down at logout). @u is the semantic drop-in; the persistent keyring would over-deliver (survive logout) and needs keyctl_get_persistent machinery we deliberately avoid.
  • Read it from the operator's own user namespace, nowhere else. A user keyring is per user namespace: a process inside podman's rootless namespace resolves @u to its own empty keyring, and the cache is invisible there however the permissions read. So this tier serves a supervisor that runs as a user unit of the operator's systemd manager, in the operator's namespaces; a supervisor inside the container namespace gets the session-file backing instead (session_cache chooses, by the same fact the OCI hook reads). No bridge through the session keyring: that reader would depend on which login cached the passphrase.
  • Explicit keyctl_setperm. A fresh user key defaults to possessor=all, uid=view — the uid can see the key but not read or search it. systemd gets away without a setperm because its readers possess @u through a shared session keyring; our CLI in a different terminal does not possess the supervisor's key, so it would fall to the uid class and be unable to find or read it. We therefore open uid view|read|write|search|setattr and zero the group/other classes — no other user can read it, and any same-uid terminal can read, revoke, or update it. Applying that mask needs the writer to possess the key, so store first links @u into the session keyring (a headless supervisor / cron / CI has no pam_keyinit possession otherwise, and the setperm would fail EACCES).
  • No auto-expiry. The cache persists for the whole login session — until an explicit vault lock (or a move to a durable tier), just like the tmpfs file it replaces — rather than timing out mid-session. The payload lives in unswappable kernel memory, so it never reaches disk or swap regardless.

Linux-only: on any host without the kernel key facility (CONFIG_KEYS off, no libkeyutils, WSL1, non-Linux) every entry point degrades to "unavailable" and the tier simply drops out of the resolution chain, exactly like systemd-creds on a systemd < 257 box.

KEY_TYPE = b'user' module-attribute

KEY_DESCRIPTION_PREFIX = b'terok-sandbox:vault-passphrase:' module-attribute

__all__ = ['KEY_DESCRIPTION_PREFIX', 'KEY_TYPE', 'forget', 'is_cached', 'key_description', 'load', 'store', 'unavailable_reason'] module-attribute

key_description(db_path)

Return the @u key description scoping the cache to one vault on this host.

Anchored on (hostname, absolute credentials-DB path) and hashed to a fixed-width, ASCII-safe token appended to KEY_DESCRIPTION_PREFIX: two vaults on one uid never collide, and a path carrying spaces or non-UTF-8 bytes can't corrupt the description. The hostname component separates environments that share one @u but differ by UTS namespace (concurrent rootless containers with identical in-container paths); the path component keeps a test's throwaway DB off the operator's real key even on the same host. abspath (not realpath) and gethostname keep this pure and stable — the writer and every same-host reader derive both from the same config and the same UTS namespace, so they always agree.

Source code in src/terok_sandbox/vault/store/kernel_keyring.py
def key_description(db_path: str | os.PathLike[str]) -> bytes:
    """Return the ``@u`` key description scoping the cache to one vault on this host.

    Anchored on ``(hostname, absolute credentials-DB path)`` and hashed to
    a fixed-width, ASCII-safe token appended to
    [`KEY_DESCRIPTION_PREFIX`][terok_sandbox.vault.store.kernel_keyring.KEY_DESCRIPTION_PREFIX]:
    two vaults on one uid never collide, and a path carrying spaces or
    non-UTF-8 bytes can't corrupt the description.  The hostname component
    separates environments that share one ``@u`` but differ by UTS
    namespace (concurrent rootless containers with identical in-container
    paths); the path component keeps a test's throwaway DB off the
    operator's real key even on the same host.  ``abspath`` (not
    ``realpath``) and ``gethostname`` keep this pure and stable — the
    writer and every same-host reader derive both from the same config and
    the same UTS namespace, so they always agree.
    """
    return KEY_DESCRIPTION_PREFIX + cache_digest(db_path).encode("ascii")

cache_digest(db_path)

Return the per-vault cache token every backing scopes its entry with.

key_description composes it into the @u description; session_file uses it as the cache file name. One derivation, so the backings always agree.

Source code in src/terok_sandbox/vault/store/kernel_keyring.py
def cache_digest(db_path: str | os.PathLike[str]) -> str:
    """Return the per-vault cache token every backing scopes its entry with.

    [`key_description`][terok_sandbox.vault.store.kernel_keyring.key_description]
    composes it into the ``@u`` description;
    [`session_file`][terok_sandbox.vault.store.session_file] uses it as
    the cache file name.  One derivation, so the backings always agree.
    """
    ident = f"{socket.gethostname()}\0{os.path.abspath(db_path)}"
    return hashlib.sha256(ident.encode("utf-8")).hexdigest()[:32]

store(passphrase, db_path)

Cache passphrase for db_path so later processes can unlock that vault.

The cache is deliberately untimed: it lives for the login session and is cleared only by an explicit vault lock or a move to a durable tier, matching the tmpfs file this tier replaces. Failure is soft — a cache is never the sole home of the secret — so an unreachable facility, an exhausted key quota or a refused permission change is logged and reported rather than raised.

Returns:

Type Description
bool

True when the passphrase is cached and readable by this uid.

Raises:

Type Description
ValueError

The passphrase is empty — SQLCipher reads that back as "no encryption" — or implausibly large for a passphrase.

Source code in src/terok_sandbox/vault/store/kernel_keyring.py
def store(passphrase: str, db_path: str | os.PathLike[str]) -> bool:
    """Cache *passphrase* for *db_path* so later processes can unlock that vault.

    The cache is deliberately untimed: it lives for the login session and
    is cleared only by an explicit ``vault lock`` or a move to a durable
    tier, matching the tmpfs file this tier replaces.  Failure is soft —
    a cache is never the sole home of the secret — so an unreachable
    facility, an exhausted key quota or a refused permission change is
    logged and reported rather than raised.

    Returns:
        True when the passphrase is cached and readable by this uid.

    Raises:
        ValueError: The passphrase is empty — SQLCipher reads that back
            as "no encryption" — or implausibly large for a passphrase.
    """
    if not passphrase:
        raise ValueError("refusing to cache an empty passphrase in the kernel keyring")
    payload = passphrase.encode("utf-8")
    if len(payload) > _MAX_PAYLOAD_BYTES:
        raise ValueError(f"passphrase exceeds {_MAX_PAYLOAD_BYTES} bytes — refusing to cache")
    try:
        lib = _load_library()
    except _KeyutilsUnavailable as exc:
        _logger.warning("kernel keyring unavailable, not caching passphrase: %s", exc)
        return False

    # Possession first: a fresh key grants the possessor everything but
    # the uid only ``view`` (0x3f010000), and on a host without a
    # pam_keyinit-linked session keyring — a headless supervisor, cron,
    # CI — this process does not possess ``@u``, so the keyctl_setperm
    # below (which needs ``setattr``) would fail EACCES.  Idempotent
    # where a login session already linked it.
    ctypes.set_errno(0)
    if lib.keyctl_link(_KEY_SPEC_USER_KEYRING, _KEY_SPEC_SESSION_KEYRING) == -1:
        _logger.warning("kernel keyring @u -> @s link failed: %s", os.strerror(ctypes.get_errno()))

    ctypes.set_errno(0)
    serial = lib.add_key(
        KEY_TYPE, key_description(db_path), payload, len(payload), _KEY_SPEC_USER_KEYRING
    )
    if serial == -1:
        _logger.warning("kernel keyring add_key failed: %s", os.strerror(ctypes.get_errno()))
        return False
    # Lock the mask down before anything can race a read on the default
    # (uid-view-only) permissions.
    if lib.keyctl_setperm(serial, _KEY_PERM) == -1:
        _logger.warning("kernel keyring keyctl_setperm failed: %s", os.strerror(ctypes.get_errno()))
        lib.keyctl_unlink(serial, _KEY_SPEC_USER_KEYRING)
        return False
    return True

load(db_path)

Return the passphrase cached for db_path.

Silent on every miss: an absent key and an unusable facility are both the ordinary "locked" outcome, which the next tier of the resolver chain handles. Reach for is_cached when only presence matters — this materialises the secret.

Returns:

Type Description
str | None

The cached passphrase, or None when nothing is cached for this vault.

Source code in src/terok_sandbox/vault/store/kernel_keyring.py
def load(db_path: str | os.PathLike[str]) -> str | None:
    """Return the passphrase cached for *db_path*.

    Silent on every miss: an absent key and an unusable facility are
    both the ordinary "locked" outcome, which the next tier of the
    resolver chain handles.  Reach for
    [`is_cached`][terok_sandbox.vault.store.kernel_keyring.is_cached]
    when only presence matters — this materialises the secret.

    Returns:
        The cached passphrase, or None when nothing is cached for this vault.
    """
    try:
        lib = _load_library()
    except _KeyutilsUnavailable:
        return None

    try:
        serial = _find_cached_key(lib, key_description(db_path))
    except OSError as exc:
        _logger.warning("kernel keyring search failed: %s", exc)
        return None
    if serial is None:
        return None
    # Sized by a first pass so the buffer is never a guess.
    length = lib.keyctl_read(serial, None, 0)
    if length <= 0:
        return None
    buf = ctypes.create_string_buffer(length)
    got = lib.keyctl_read(serial, buf, length)
    if got <= 0:
        return None
    try:
        return buf.raw[:got].decode("utf-8") or None
    finally:
        # The decoded str is out of our hands; this buffer is not.
        ctypes.memset(buf, 0, length)

forget(db_path)

Clear the passphrase cached for db_path.

Backs vault lock. An already-absent key counts as success: the contract is the end state — nothing cached for this vault — not the act of removing something. A lookup that fails is not that end state, so it reports failure rather than claim the passphrase is gone. Any same-uid terminal may call it, not only the one that cached the passphrase.

The key is anchored in @u and this unlinks it from there, so clearing the cache is an operator-context operation.

Returns:

Type Description
bool

True when no passphrase remains cached for this vault.

Source code in src/terok_sandbox/vault/store/kernel_keyring.py
def forget(db_path: str | os.PathLike[str]) -> bool:
    """Clear the passphrase cached for *db_path*.

    Backs ``vault lock``.  An already-absent key counts as success: the
    contract is the end state — nothing cached for this vault — not the
    act of removing something.  A lookup that *fails* is not that end
    state, so it reports failure rather than claim the passphrase is
    gone.  Any same-uid terminal may call it, not only the one that
    cached the passphrase.

    The key is anchored in ``@u`` and this unlinks it from there, so
    clearing the cache is an operator-context operation.

    Returns:
        True when no passphrase remains cached for this vault.
    """
    try:
        lib = _load_library()
    except _KeyutilsUnavailable:
        return True

    try:
        serial = _find_cached_key(lib, key_description(db_path))
    except OSError as exc:
        _logger.warning("kernel keyring search failed, cannot confirm removal: %s", exc)
        return False
    if serial is None:
        return True
    if lib.keyctl_unlink(serial, _KEY_SPEC_USER_KEYRING) == -1:
        _logger.warning("kernel keyring keyctl_unlink failed: %s", os.strerror(ctypes.get_errno()))
        return False
    return True

is_cached(db_path)

Whether a passphrase is currently cached for db_path.

The presence question every status surface asks — vault status, the doctor checks, the TUI pill's poll — answered without reading the payload, so reporting on the secret never materialises it.

Returns:

Type Description
bool

True when this vault's key exists in the user keyring.

Source code in src/terok_sandbox/vault/store/kernel_keyring.py
def is_cached(db_path: str | os.PathLike[str]) -> bool:
    """Whether a passphrase is currently cached for *db_path*.

    The presence question every status surface asks — ``vault status``,
    the doctor checks, the TUI pill's poll — answered without reading
    the payload, so reporting *on* the secret never materialises it.

    Returns:
        True when this vault's key exists in the user keyring.
    """
    try:
        lib = _load_library()
    except _KeyutilsUnavailable:
        return False
    try:
        return _find_cached_key(lib, key_description(db_path)) is not None
    except OSError as exc:
        _logger.warning("kernel keyring search failed: %s", exc)
        return False

unavailable_reason()

Explain why this host cannot hold the cache, or None if it can.

The gate the setup chooser and the status surfaces consult before offering the tier, mirroring systemd_creds.unavailable_reason so both tiers are gated alike. A probe, not a guarantee — store's return value is the definitive answer — and it neither creates nor reads a key.

Returns:

Type Description
str | None

A human-readable reason the tier is unusable here, or None when

str | None

it is usable.

Source code in src/terok_sandbox/vault/store/kernel_keyring.py
def unavailable_reason() -> str | None:
    """Explain why this host cannot hold the cache, or ``None`` if it can.

    The gate the setup chooser and the status surfaces consult before
    *offering* the tier, mirroring
    [`systemd_creds.unavailable_reason`][terok_sandbox.vault.store.systemd_creds.unavailable_reason]
    so both tiers are gated alike.  A probe, not a guarantee —
    [`store`][terok_sandbox.vault.store.kernel_keyring.store]'s return
    value is the definitive answer — and it neither creates nor reads a
    key.

    Returns:
        A human-readable reason the tier is unusable here, or None when
        it is usable.
    """
    try:
        lib = _load_library()
    except _KeyutilsUnavailable as exc:
        return str(exc)
    # keyctl_get_keyring_ID(@u, create=0): resolves the user keyring's
    # real serial without creating anything.  ENOSYS ⇒ kernel built
    # without CONFIG_KEYS (or a syscall-translation layer like WSL1);
    # any other failure ⇒ the tier can't run here.
    ctypes.set_errno(0)
    if lib.keyctl_get_keyring_ID(_KEY_SPEC_USER_KEYRING, 0) != -1:
        return None
    err = ctypes.get_errno()
    if err == errno.ENOSYS:
        return "kernel built without keyring support (CONFIG_KEYS)"
    return f"user keyring unreachable ({os.strerror(err)})"