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 so libfido2 can show the touch prompt and receive input
  • Captures SSH stdout via a pipe to extract the MOSH CONNECT <port> <key> line emitted by mosh-server
  • Execs mosh-client directly 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-client and mosh-server installed as usual
  • OpenSSH 8.2+ with FIDO2 support
  • libfido2 (provided by Homebrew’s libfido2 or distro package)