Thunderbird’s already flaky GPG smartcard support falls down when using Flatpak. But fear not - I managed to get it working!

Summary

To get your Yubikey working on Fedora Silverblue with Thunderbird, follow these steps:

  1. Fix hotplug1:

    $ cat >> ~/.gnupg/scdaemon.conf <<EOF
    disable-ccid
    pcsc-shared
    EOF
    $
    
  2. Enable gpg-agent.socket2:

    systemctl --user enable --now gpg-agent.socket
    
  3. Grant Thunderbird access to the system gpg-agent instead of running its own agent:

    flatpak override --user --socket=gpg-agent --nosocket=pcsc '--nofilesystem=~/.gnupg' net.thunderbird.Thunderbird
    flatpak override --user '--filesystem=~/.gnupg:ro'
    
  4. Fix the session DBus3:

    sudo tee /opt/pinentry.sh <<EOF
    #!/bin/env -S --ignore-environment /bin/sh
       
    # The shebang using env ensures that all (potentially malicious) environment variables are cleared
       
    # Set the correct DBus session bus path - this is hardcoded because we don't trust the environment we receive if called from a sandboxed caller
    export DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$(/usr/bin/id -ru)/bus"
    # Set a placeholder display, otherwise the curses fallback is sometimes used.
    # In particular this makes a difference if the caller doesn't specify --display.
    # This might break if you use X11, but if you are, stop, and use Wayland instead.
    export DISPLAY="__stub"
       
    # And finally pass everything else through
    exec /usr/bin/pinentry "$@"
    EOF
    sudo chown root:root /opt/pinentry.sh
    sudo chmod 0755 /opt/pinentry.sh
    sudo restorecon /opt/pinentry.sh
    echo "pinentry-program /opt/pinentry.sh" >> ~/.gnupg/gpg-agent.conf
    
  5. Reboot (I mean it!)

  6. Follow the normal setup instructions for Thunderbird

Other useful steps you might want to take are found in the community guide.

Thunderbird debugging

I started off looking at the Thunderbird error log (Ctrl+Shift+J), but it was rather unhelpful:

mailnews.send: NS_ERROR_XPC_JAVASCRIPT_ERROR_WITH_DETAILS: [JavaScript Error: "encryptMessageStart FAILED: -1" {file: "chrome://openpgp/content/modules/mimeEncrypt.sys.mjs" line: 455}]'[JavaScript Error: "encryptMessageStart FAILED: -1" {file: "chrome://openpgp/content/modules/mimeEncrypt.sys.mjs" line: 455}]' when calling method: [nsIMsgComposeSecure::finishCryptoEncapsulation]

Annoyingly that code was quite tricky to trace but I eventually found that it called into a library called GPGME.

GPGME debugging

Thankfully, there is pdocumentation on how to debug GPGME!

flatpak run --env=GPGME_DEBUG=9 net.thunderbird.Thunderbird

And suddenly I was able to see the actual error while trying to send signed mail!

2025-08-13 11:05:34 gpgme[2.2]     gpgme_op_keylist_next:1367: error: End of file <GPGME>\n
2025-08-13 11:05:34 gpgme[2.2]     gpgme_release: call: ctx=0x00007f633fdfeac0 
2025-08-13 11:05:34 gpgme[2.2]     gpgme_data_release: call: dh=0x0000000000000000 
2025-08-13 11:05:34 gpgme[2.2]     gpgme_data_release: call: dh=0x00007f633e165000 
2025-08-13 11:05:34 gpgme[2.2]   gpgme_get_key:1495: error: End of file <GPGME>\n

Okay, not a super helpful error. But scrolling up a bit (a lot, these logs are super verbose), I found something more useful:

2025-08-13 11:05:34 gpgme[2.2]         _gpgme_io_read: check: gpg: error running '/usr/bin/gpg-agent': exit st
2025-08-13 11:05:34 gpgme[2.2]         _gpgme_io_read: check: atus 2<LF>
2025-08-13 11:05:34 gpgme[2.2]         _gpgme_io_read: check: gpg: failed to start gpg-agent '/usr/bin/gpg-age
2025-08-13 11:05:34 gpgme[2.2]         _gpgme_io_read: check: nt': General error<LF>
2025-08-13 11:05:34 gpgme[2.2]         _gpgme_io_read: check: gpg: can't connect to the gpg-agent: General err
2025-08-13 11:05:34 gpgme[2.2]         _gpgme_io_read: check: or<LF>
2025-08-13 11:05:34 gpgme[2.2]         _gpgme_io_read: check: gpg: keydb_search failed: No agent running<LF>
2025-08-13 11:05:34 gpgme[2.2]         _gpgme_io_read: check: gpg: error reading key: No agent running<LF>

The GPG agent

So it looks like we’re struggling to connect to the GPG agent. Looking at the pid of the GPG agent from outside the flatpak sandbox, I was able to confirm this:

$ pidof gpg-agent
$ 

Initially I decided to postpone fixing this issue and just manually start a GPG agent to see if we could make any other progress, so I just started an agent manually by calling a GPG command on the host.

$ gpg --card-status
<snip>
$ pidof gpg-agent
24067
$ 

Great, let’s try sending another email.

2025-08-13 11:08:52 gpgme[2.2]   gpgme_op_sign:522: error: pinentry error <Pinentry>\n

Pinentry inside the container

A new error! My first instinct here was that the sandbox was blocking us from requesting the PIN, so I decided to test it out inside the Flatpak:

$ flatpak enter net.thunderbird.Thunderbird bash
bash-5.2$ pinentry
OK Pleased to meet you
GETPIN
<snip>

Unfortunately, this opened a Curses interface.

bash-5.2$ pinentry --help
pinentry-curses (pinentry) 1.3.1-unknown

Wrong pinentry… but there’s a pinentry-gnome3 binary installed in the Flatpak.

bash-5.2$ pinentry-gnome3 --debug
No Gcr System Prompter available, falling back to curses
OK Pleased to meet you

It complained that there was something wrong with the connection to gcr, but wouldn’t say what.

At this point I went down a rabbithole and compiled my own version of pinentry-gnome3 with the fallback disabled, which showed me the actual error message. Basically it was complaining that a session-bus DBus call was failing (although it didn’t say what).

DBus allowlisting

Luckily, Flatpak has documentation on monitoring DBus traffic. Following this, I saw the following:

Filtering message due to arg0 org.gnome.keyring.SystemPrompter, policy: 0 (required 1)
C4: -> org.gnome.keyring.SystemPrompter call org.gnome.keyring.internal.Prompter.BeginPrompting at /org/gnome/keyring/Prompter

At this point, I tried granting org.gnome.keyring.* to the Flatpak to see if it would fix pinentry, and it did!

bash-5.2$ pinentry-gnome3 --debug
OK Pleased to meet you
GETPIN
D testing
OK
^C
bash-5.2$

This got me excited so I tried sending an email, but I still got the exact same error:

gpgme_op_sign:522: error: pinentry error <Pinentry>\n

This was most discouraging as it suggested I had fixed the wrong problem, especially as there was nothing in the DBus logs.

Pinentry logging

I decided to take a new approach and try calling gpg manually inside the flatpak container. To my surprise, this worked! And what’s more, after manually signing a message (which correctly prompted for my PIN), I was able to send emails through Thunderbird!

This was definitely progress but after restarting Thunderbird it was broken again. I decided to try to get access to the actual error returned by pinentry in real operation.

I created a bash wrapper script:

#!/bin/sh
echo "pinentry called with:" "$@" >> ~/pinentry.log
tee -a ~/pinentry.in.log | pinentry --debug 2>>~/pinentry.err.log | tee -a ~/pinentry.out.log

I configured it with pinentry-program in gpg-agent.conf. At this point after failing to send an email I had full logs from the pinentry.

Most interesting was the stderr output:

failed to connect to user session D-Bus (1): Could not connect: No such file or directory
Timeout: the Gcr system prompter was already in use.

This initially surprised me as I had been able to connect to the session bus in my manual tests of pinentry. In order to try to reproduce the issue, I decided to have my script print the entire env and /proc/$$/status into my log files.

I noticed that the PPid was one of two gpg-agent processes running on my system!

At this point, I wanted to ensure that there was only one gpg-agent, and I wanted it outside the sandbox so that I could use it from the console. A quick search online told me that it was a one-liner!

I did this and rebooted. I also noticed in Flatseal that I could enable gpg-agent socket sharing but that this was currently disabled. I enabled this and disabled the pcsc socket to avoid conflicts.

At this point I was still encountering the same error but at least I knew that the pinentry was meant to be running outside the sandbox, and the PPid confirmed that. However, the same error was still occurring.

I noticed in the logs that there was a DBus environment variable set:

DBUS_SESSION_BUS_ADDRESS=unix:path=/run/flatpak/bus

Containment confusion

This was most odd: we know that pinentry is not running in Flatpak, so it definitely can’t access a Flatpak DBus proxy.

At this point, I was pretty sure I had my culprit. After some extensive searching I found that gpg agent clients are allowed to send arbitrary environment variables to pinentry using OPTION putenv.

Sadly, there doesn’t seem to be a way to disable putenv. It sounds like a sandbox escape waiting to happen. To test my hypothesis, I grabbed my unsandboxed DBus address:

$ echo $DBUS_SESSION_BUS_ADDRESS
unix:path=/run/user/1000/bus
$ 

and I entered this manually into my pinentry.sh wrapper. Lo and behold, I was able to send signed emails!

However, now that I knew about the putenv option, I realised that the wrapper would need some additional security measures to avoid vulnerabilities. For example, I wanted to use /run/user/$(id -ru)/bus as the session bus, but calling id could be subject to PATH Interception. To avoid this, I decided to use env -i:

#!/bin/env -S --ignore-environment /bin/sh

which invokes the shell script with an entirely clean environment.

Finally I dumped the script in /opt and configured it.

#!/bin/env -S --ignore-environment /bin/sh

# The shebang using env ensures that all (potentially malicious) environment variables are cleared

# Set the correct DBus session bus path - this is hardcoded because we don't trust the environment we receive if called from a sandboxed caller
export DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$(/usr/bin/id -ru)/bus"

# And finally pass everything else through
exec /usr/bin/pinentry "$@"
pinentry-program /opt/pinentry.sh

At this point, I had working emails. After some testing, it seems that this also fixed decryption for free.

This broke some other programs - turns out that the curses fallback is activated if DISPLAY is unset and --display is not passed. Setting DISPLAY=__stub fixed this.

Alternatives

I decided to run the GPG agent outside the sandbox. In theory, it could run inside the Flatpak (and that seems to be the design intended by the Thunderbird team), but this would come with some tradeoffs:

  • This means that pinentry would run inside the sandbox which means a compromised container could read your smartcard PIN, potentially allowing other credentials to be compromised.
  • On the other hand, running pinentry outside the sandbox increases the attack surface for a sandbox escape. This is arguably mitigated by removing access to pcscd which is no longer required.
  • Most importantly, running gpg-agent outside the sandbox means that ~/.gnupg can be exposed to the sandbox in read-only mode. When this is read-write, sandbox escape is trivial as it can just edit the configuration file to make the system-wide GPG agent invoke a malicious pinentry program.

I also wasn’t able to get the agent working reliably inside the sandbox, which is the main reason for running it outside.

Conclusion

At the start of this process I knew very little about GPG’s internal workings and now I feel like I do at least understand some of the PIN entry architecture and the purpose of the agent.

It also reminded me of some of my Flatpak knowledge that was getting rusty. I should probably have noticed faster that the pinentry call was in the wrong environment when it didn’t show up in the DBus logs during email sending attempts.

  1. Thanks to Ludovic Rousseau for finding this one. 

  2. Thanks to Vladimir Panteleev’s answer for saving me lots of time figuring this out. 

  3. This won’t work over ssh unless you have an active graphical session, in which case you’ll get prompted in the graphical session. If you need ssh support, you’ll need to write a (secure) wrapper that chooses correctly between gcr and curses/tty prompting according to where it’s called from. If passing the tty variables through correctly the built-in curses fallback might work. Generally you probably only want to use a smartcard on a graphical session anyway (otherwise look into agent forwarding) but you might need a non-graphical pinentry for symmetric non-smartcard-backed encryption. If you do, you can always temporarily comment out the changes to ~/.gnupg/gpg-agent.conf