Larvitz Blog

FreeBSD, Linux, all things cleanly engineered

Card Wars: Hiding Smartcard Readers from Eager Rust Agents with LD_PRELOAD



Running multiple hardware security tokens simultaneously sounds straightforward until you try it. I recently built what I affectionately call a “Workstation from Hell”. A Fedora setup with two distinct smartcard ecosystems that absolutely refuse to get along:

  1. Enterprise Layer: An Aventra MyEID card (PKCS#15) in the internal laptop reader for Kerberos/PKINIT authentication
  2. GPG Layer: A Nitrokey 3 (OpenPGP) for SSH and Git signing, managed by the modern Rust-based openpgp-card-ssh-agent

The MyEID handles enterprise authentication. The Nitrokey handles developer workflows. In theory, they should coexist without issue. In practice, one of them throws a tantrum every time it sees the other.

The Problem: Aggressive Slot Scanning

The Rust OpenPGP stack is modern, fast, and memory-safe. It’s also a bit eager. When the SSH agent starts, it scans all available PC/SC slots looking for OpenPGP cards - a reasonable approach when you’re the only smartcard on the system.

When it encounters my internal Alcor reader containing the MyEID card, things go sideways. The MyEID speaks strict PKCS#15, not OpenPGP. The Rust stack receives unexpected response codes, interprets them as a fatal protocol failure, and crashes:

Jan 17 19:42:43 neochristop openpgp-card-ssh-agent[44822]: [2026-01-17T18:42:43Z ERROR ssh_agent_lib::agent] Error handling message: Proto(UnsupportedCommand { command: 27 })
Jan 17 19:42:43 neochristop openpgp-card-ssh-agent[44822]: [2026-01-17T18:42:43Z ERROR ssh_agent_lib::agent] Error handling message: Failure

The agent doesn’t offer a configuration option to whitelist specific readers. I needed a way to make the internal reader invisible to the Rust agent without affecting the rest of the system - the Kerberos stack still needs full access to the MyEID card.

The Solution: LD_PRELOAD Interception

If the application won’t filter readers, we filter them at the system API level. The PC/SC API uses SCardListReaders to enumerate available smartcard slots. By intercepting this function, we can surgically remove specific readers from the list before the application sees them.

The approach is straightforward:

  1. Create a shared library that intercepts SCardListReaders
  2. Call the real function to get the complete reader list
  3. Remove any entries matching our filter criteria
  4. Return the filtered list to the application

This keeps the Kerberos stack happy (it sees both readers) while the OpenPGP agent only sees the reader it can actually work with.

The Shim Library

Here’s the C code that makes this work:

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <dlfcn.h>
#include <winscard.h>

// Function pointer matching the PC/SC prototype
typedef LONG (*SCardListReaders_t)(SCARDCONTEXT, LPCSTR, LPSTR, LPDWORD);

LONG SCardListReaders(SCARDCONTEXT hContext, LPCSTR mszGroups,
                      LPSTR mszReaders, LPDWORD pcchReaders) {
    // Load and call the original function
    SCardListReaders_t original_fn =
        (SCardListReaders_t)dlsym(RTLD_NEXT, "SCardListReaders");
    LONG rv = original_fn(hContext, mszGroups, mszReaders, pcchReaders);

    // If the call failed or we're just querying buffer size, return immediately
    if (rv != SCARD_S_SUCCESS || mszReaders == NULL || pcchReaders == NULL) {
        return rv;
    }

    // Filter the multi-string list
    // PC/SC returns readers as "Reader A\0Reader B\0\0"
    char *p = mszReaders;
    char *writer = mszReaders;
    DWORD new_len = 0;
    DWORD total_len = *pcchReaders;

    while (p < mszReaders + total_len) {
        size_t len = strlen(p);
        if (len == 0) break;  // Double null indicates end of list

        // FILTER LOGIC: If "Alcor" is in the name, skip it
        if (strstr(p, "Alcor") != NULL) {
            // Skip this reader (hide it from the application)
        } else {
            // Keep this reader: shift bytes if necessary
            if (writer != p) {
                memmove(writer, p, len + 1);
            }
            writer += len + 1;
            new_len += len + 1;
        }
        p += len + 1;
    }

    // Terminate the new list properly
    *writer = '\0';
    new_len++;

    // Update the length returned to the caller
    *pcchReaders = new_len;

    return rv;
}

The key insight is that PC/SC returns reader names as a multi-string buffer, a sequence of null-terminated strings followed by an extra null byte. The filter walks this structure, copying entries that don’t match the filter pattern while skipping those that do.

Compilation and Installation

Building the shim requires gcc and the PC/SC headers. On Fedora:

sudo dnf install pcsc-lite-devel gcc
mkdir -p ~/.local/lib

gcc -shared -fPIC -o ~/.local/lib/libhackyreaderfix.so \
    hacky_reader_fix.c -ldl -I/usr/include/PCSC

The -shared -fPIC flags create a position-independent shared library suitable for LD_PRELOAD injection. The -ldl links against the dynamic loader library for the dlsym call.

Injecting the Shim

I use systemd --user to manage my SSH agent. To inject the library, create a drop-in override:

systemctl --user edit openpgp-card-ssh-agent.service

Add the following content:

[Service]
Environment="LD_PRELOAD=%h/.local/lib/libhackyreaderfix.so"

The %h specifier expands to the user’s home directory, keeping the configuration portable.

After saving, restart the service:

systemctl --user daemon-reload
systemctl --user restart openpgp-card-ssh-agent

The Result

With the shim in place, the two security stacks are perfectly isolated:

  • System/Kerberos: Sees both readers, including the internal Alcor with the MyEID card. PKINIT authentication works normally.
  • SSH Agent: Completely unaware that the Alcor reader exists. Connects directly to the Nitrokey in the external Identiv reader. No more crashes, no more protocol errors.

You can verify the filtering is working using pcsc_scan (from the pcsc-tools package) to see exactly what the shim exposes:

# Without LD_PRELOAD - shows both readers
pcsc_scan
# Reader 0: Identiv uTrust 3512 SAM slot Token [CCID Interface] (55512030603915) 00 00
# Reader 1: Alcor Link AK9563 01 00
# Reader 2: Nitrokey Nitrokey 3 [CCID/ICCD Interface] 02 00

# With LD_PRELOAD - only shows the allowed reader
LD_PRELOAD=~/.local/lib/libhackyreaderfix.so pcsc_scan
# Reader 0: Identiv uTrust 3512 SAM slot Token [CCID Interface] (55512030603915) 00 00
# Reader 1: Nitrokey Nitrokey 3 [CCID/ICCD Interface] 02 00

Extending the Filter

The current implementation hardcodes “Alcor” as the filter pattern. For more flexibility, you could read the pattern from an environment variable:

const char *filter = getenv("PCSC_HIDE_READER");
if (filter != NULL && strstr(p, filter) != NULL) {
    // Skip this reader
}

Then configure it via systemd:

[Service]
Environment="LD_PRELOAD=%h/.local/lib/libhackyreaderfix.so"
Environment="PCSC_HIDE_READER=Alcor"

This makes the shim reusable across different reader combinations without recompilation.

Why Not Upstream a Fix?

The right solution would be for the Rust OpenPGP stack to handle non-OpenPGP cards gracefully. Either by ignoring readers that return unexpected responses or by offering a configuration option to whitelist readers. Both would be reasonable feature requests.

However, the LD_PRELOAD approach solves the immediate problem without waiting for upstream changes, and it works for any application that misbehaves when confronted with unexpected smartcard readers. It’s a general-purpose tool for a specific class of problems.

Caveats

A few things to keep in mind:

  • Security implications: LD_PRELOAD modifies application behavior at runtime. Only use libraries you control and trust.
  • Fragility: If the PC/SC API changes significantly, the shim may need updates. In practice, this API has been stable for decades.
  • Debugging: If something goes wrong, remember the shim is in the path. Temporarily removing the LD_PRELOAD can help isolate issues.

Conclusion

Sometimes the cleanest solution is a dirty hack. LD_PRELOAD interception isn’t elegant, but it’s effective. When two pieces of software refuse to cooperate and neither offers the configuration knobs you need, a small shim library can bridge the gap.

The MyEID handles enterprise authentication. The Nitrokey handles my development workflow. And a few dozen lines of C keep them from stepping on each other’s toes.


References


The best kind of interoperability hack is one you can forget about. Set it up once, and your hardware security layers peacefully ignore each other forever.