Problem
When using mosh with a FIDO2-backed SSH key (sk-ed25519 / sk-ecdsa, e.g. YubiKey), the touch prompt is never shown. The YubiKey blinks β meaning it received the signing request β but the terminal hangs silently until timeout.
This affects any tool that invokes SSH as a subprocess without a proper controlling TTY, including mosh and ansible.
Root Cause
Mosh calls SSH internally with the -n flag:
ssh -n -tt -S none -o ProxyCommand=... <host> -- mosh-server new ...
The -n flag redirects SSH’s stdin from /dev/null. libfido2 needs a real /dev/tty to print the touch prompt. With -n in effect, the signing request reaches the YubiKey hardware (hence the blinking) but the prompt is swallowed and there is no way to respond.
This is a known, acknowledged bug in Mosh’s SSH handshake TTY handling, reported as far back as 2012 and never fixed. The Mosh project is low-activity and no upstream fix is expected.
The Fix
A drop-in replacement for /usr/bin/mosh (or /opt/homebrew/bin/mosh on macOS) that:
- Runs SSH without
-n, leaving stdin and stderr connected to the real terminal solibfido2can show the touch prompt and receive input - Captures SSH stdout via a pipe to extract the
MOSH CONNECT <port> <key>line emitted bymosh-server - Execs
mosh-clientdirectly with the key and port once the handshake completes
Everything else β SSH options, locale forwarding, port ranges, predict modes, address family flags β behaves identically to the original mosh script.
Here is the drop-in file:
#!/usr/bin/env perl
#
# mosh-fido2 β drop-in replacement for /usr/bin/mosh that fixes FIDO2/YubiKey
# touch prompts being silently swallowed during the SSH handshake.
#
# ROOT CAUSE:
# The real mosh passes `-n` to SSH, which redirects stdin from /dev/null.
# libfido2 needs a real /dev/tty to print "Confirm user presence for key..."
# With -n, the request reaches the YubiKey (it blinks) but the prompt is
# never shown, and the connection hangs or times out.
#
# THE FIX:
# We intercept the SSH handshake ourselves:
# - Drop `-n` so SSH keeps stdin/stderr on /dev/tty (touch prompt visible)
# - Run SSH with stdout piped so we can extract the MOSH key/port line
# - Parse MOSH_KEY and MOSH_PORT from SSH output
# - Exec mosh-client directly with those values
#
# INSTALL:
# sudo cp mosh-fido2 /usr/local/bin/mosh-fido2
# sudo chmod +x /usr/local/bin/mosh-fido2
# # Optional: replace mosh entirely
# # sudo cp /usr/bin/mosh /usr/bin/mosh.orig && sudo cp mosh-fido2 /usr/bin/mosh
#
# USAGE: identical to mosh
# mosh-fido2 user@host
# mosh-fido2 --ssh="ssh -p 2222" user@host
#
# REQUIREMENTS: perl, mosh-client, mosh-server (on remote), ssh
#
# Based on mosh 1.4.0 (GPLv3). Original: https://github.com/mobile-shell/mosh
use strict;
use warnings;
use POSIX qw(:sys_wait_h);
use Socket;
use IO::Handle;
use Getopt::Long qw(:config no_ignore_case bundling);
my $MOSH_VERSION = "1.4.0-fido2fix";
sub usage {
print STDERR <<END;
Usage: mosh-fido2 [options] [--] [user\@]host [command...]
Drop-in replacement for mosh with FIDO2/YubiKey touch prompt fix.
Options (same as mosh):
--ssh=COMMAND ssh command to use (default: ssh)
--ssh-pty (ignored, we always use pty for FIDO2)
-p, --port=PORT[:PORT2] server-side UDP port or range
--predict=MODE local echo prediction (default/always/never/adaptive)
--family=FAMILY address family (inet/inet6/auto/all/prefer-inet/prefer-inet6)
--no-ssh-pty disable ssh pty (breaks FIDO2, not recommended)
--no-init don't send terminal init string
--local run mosh-server locally
--experimental-remote-ip=(local|remote|proxy)
-4 use IPv4 only
-6 use IPv6 only
--help show this help
--version show version
END
exit 1;
}
# --- Parse arguments (mirrors mosh option set) ---
my $ssh_cmd = 'ssh';
my $port_request;
my $predict;
my $family;
my $no_init = 0;
my $local = 0;
my $no_ssh_pty = 0;
my $remote_ip_mode = 'proxy';
my $ipv4_only = 0;
my $ipv6_only = 0;
my $help = 0;
my $version = 0;
GetOptions(
'ssh=s' => \$ssh_cmd,
'ssh-pty' => sub {}, # ignored β we always do it
'no-ssh-pty' => \$no_ssh_pty,
'p|port=s' => \$port_request,
'predict=s' => \$predict,
'family=s' => \$family,
'no-init' => \$no_init,
'local' => \$local,
'experimental-remote-ip=s' => \$remote_ip_mode,
'4' => \$ipv4_only,
'6' => \$ipv6_only,
'help' => \$help,
'version' => \$version,
) or usage();
if ($help) { usage(); }
if ($version) { print "mosh-fido2 $MOSH_VERSION (FIDO2 touch-prompt fix)\n"; exit 0; }
my $userhost = shift @ARGV or usage();
my @remote_cmd = @ARGV;
# --- Build SSH command ---
my @ssh = split /\s+/, $ssh_cmd;
my @sshopts;
# Address family
if ($ipv4_only) {
push @sshopts, '-4';
} elsif ($ipv6_only) {
push @sshopts, '-6';
} elsif ($family) {
if ($family =~ /^inet6$/) {
push @sshopts, '-6';
} elsif ($family =~ /^inet$/) {
push @sshopts, '-4';
}
# prefer-* and auto/all: let SSH config handle it
}
# Force TTY allocation β needed for FIDO2 touch prompt on local side.
# NOTE: We intentionally do NOT add -n here. That is the fix.
push @sshopts, '-tt' unless $no_ssh_pty;
# Disable SSH multiplexing (mosh needs its own clean connection)
push @sshopts, ('-S', 'none');
# Build the mosh-server command
my $server = 'mosh-server';
my @server_args = ('new', '-c', '256', '-s');
if ($port_request) {
push @server_args, ('-p', $port_request);
}
# Pass locale environment variables
my @locale_vars = qw(LANG LANGUAGE LC_ALL LC_CTYPE LC_NUMERIC LC_TIME
LC_COLLATE LC_MONETARY LC_MESSAGES LC_PAPER LC_NAME
LC_ADDRESS LC_TELEPHONE LC_MEASUREMENT LC_IDENTIFICATION);
for my $var (@locale_vars) {
if (defined $ENV{$var} && $ENV{$var} ne '') {
push @server_args, ('-l', "$var=$ENV{$var}");
}
}
# Append remote command if given
if (@remote_cmd) {
push @server_args, ('--', @remote_cmd);
}
# Shell-quote server args for passing via SSH
sub shell_quote {
my @args = @_;
return join ' ', map { (my $a = $_) =~ s/'/'\\''/g; "'$a'" } @args;
}
my $server_cmd = $server . ' ' . shell_quote(@server_args);
# Full SSH exec argv β stdout will be piped, stderr+stdin go to /dev/tty
my @exec_ssh = (@ssh, @sshopts, $userhost, '--', $server_cmd);
# --- Run SSH, capturing stdout while keeping /dev/tty for touch prompt ---
# We open a pipe for stdout only. stdin and stderr remain on the real terminal
# so that:
# - FIDO2 "Confirm user presence" prompt appears (stderr β /dev/tty)
# - Touch input is read (stdin β /dev/tty)
# - mosh-server startup line is captured (stdout β pipe)
my ($rh, $wh);
pipe($rh, $wh) or die "pipe: $!";
my $pid = fork();
die "fork: $!" unless defined $pid;
if ($pid == 0) {
# Child: run SSH
close $rh;
# Redirect stdout to pipe
open(STDOUT, '>&', $wh) or die "dup stdout: $!";
close $wh;
# stdin and stderr stay as-is (connected to terminal β FIDO2 prompt works)
exec @exec_ssh or die "exec ssh: $!";
}
# Parent: read SSH stdout looking for MOSH_KEY
close $wh;
my $mosh_key = '';
my $mosh_port = '';
my $connect_ip = '';
while (my $line = <$rh>) {
# mosh-server emits lines like:
# MOSH CONNECT 60001 <base64key>
if ($line =~ /^MOSH CONNECT (\d+) (\S+)/) {
$mosh_port = $1;
$mosh_key = $2;
}
# Also capture the IP mosh-server reports (for --experimental-remote-ip)
if ($line =~ /^MOSH IP (\S+)/) {
$connect_ip = $1;
}
}
close $rh;
# Wait for SSH to exit
waitpid($pid, 0);
my $ssh_exit = $? >> 8;
if (!$mosh_key || !$mosh_port) {
print STDERR "$0: Did not find mosh server startup message.\n";
print STDERR "$0: (SSH exited $ssh_exit. Is mosh-server installed on the remote?)\n";
exit 1;
}
# Determine remote IP to connect to
my $remote_addr = $userhost;
$remote_addr =~ s/^.*@//; # strip user@ prefix
if ($remote_ip_mode eq 'remote' && $connect_ip) {
$remote_addr = $connect_ip;
}
# --- Exec mosh-client ---
my @client_args = ('mosh-client');
push @client_args, '-4' if $ipv4_only;
push @client_args, '-6' if $ipv6_only;
push @client_args, '--no-init' if $no_init;
if ($predict) {
push @client_args, "--predict=$predict";
}
push @client_args, ($remote_addr, $mosh_port);
$ENV{MOSH_KEY} = $mosh_key;
exec @client_args or die "exec mosh-client: $!";
Installation (macOS + Homebrew)
# Backup the original
cp /opt/homebrew/bin/mosh /opt/homebrew/bin/mosh.orig
# Deploy the fix
cp mosh-fido2 /opt/homebrew/bin/mosh
chmod +x /opt/homebrew/bin/mosh
# Optional: prevent Homebrew from overwriting it on upgrade
brew pin mosh
Installation (Linux)
# Backup the original
cp /usr/bin/mosh /usr/bin/mosh.orig
# Deploy the fix
cp mosh-fido2 /usr/bin/mosh
chmod +x /usr/bin/mosh
Restoring the Original
# From backup
cp /opt/homebrew/bin/mosh.orig /opt/homebrew/bin/mosh
# Or just reinstall via Homebrew
brew reinstall mosh
Usage
Identical to standard mosh:
mosh user@host
mosh --ssh="ssh -p 2222" user@host
mosh user@host -- echo "connected ok" # quick handshake test
On successful connection you will now see:
Confirm user presence for key ED25519-SK SHA256:...
Touch your YubiKey and the session proceeds normally.
Requirements
- Perl (standard on macOS and Linux)
mosh-clientandmosh-serverinstalled as usual- OpenSSH 8.2+ with FIDO2 support
libfido2(provided by Homebrew’slibfido2or distro package)