Note: This project is entirely generated by large language models (LLMs).
Use your phone (iPhone / Android) as a FIDO2 security key on a Linux desktop: no browser changes, no extension, and no key material on the machine — the passkey always lives on the phone.
ucabled registers a virtual USB HID FIDO2 device with the kernel
(/dev/uhid) and relays WebAuthn registration/sign-in requests from the
browser to the phone over caBLE v2 (QR code + BLE proximity proof + WSS
tunnel, the hybrid transport from CTAP 2.2 §11.5), then sends the phone's
response back to the browser.
┌─────────────┐
│ Browser │
│ WebAuthn │
└──────┬──────┘
│ CTAP-HID over /dev/uhid
V
┌────────────────────┐ caBLE v2: BLE + WSS ┌──────────────────┐
│ ucabled │<─────────────────────────────>│ iPhone / Android │
│ (system service) │ │ passkey store │
└──────────┬─────────┘ └────────┬─────────┘
│ D-Bus org.ucabled.Manager1 ^
│ Prompt / Found / Close .
V .
┌────────────────────┐ .
│ ucable-agent │ phone scans .
│ (session agent) │ the QR code .
└──────────┬─────────┘ .
│ spawn (QR URL via stdin) .
V .
┌─────────────────────┐ .
│ ucable-agent-helper │ . . . . . . . . . . . . . . . . . . . .
│ QR window │
└─────────────────────┘
The machine is only a relay: no FIDO cryptography and no keys at rest. The one exception to pure pass-through is a small set of local compatibility answers and rewrites (getInfo, Firefox's probe requests, and an iOS-required rp.name/user.displayName injection); rpId parsing is read-only, purely to title the QR window.
- Verified on NixOS + Firefox + iPhone, both registration and sign-in, with all three tested iOS passkey providers: iCloud Keychain, Google Password Manager and Strongbox.
- Android (Google Password Manager) speaks the same protocol and is expected to work, but has not been tested on real hardware.
- Requires Linux (
/dev/uhid), BlueZ/Bluetooth enabled, and a browser that can see hidraw. Flatpak/Snap browsers need extra udev configuration; the native NixOS packages work out of the box.
Add this repository to your flake and enable the module:
{
inputs.ucabled.url = "github:fuzy112/ucabled/master";
# in your NixOS module:
imports = [ inputs.ucabled.nixosModules.ucabled ];
services.ucabled.enable = true;
# optional: use an alternate UI helper
services.ucabled.helper = "${pkgs.ucable-agent-helper-gnome}/bin/ucable-agent-helper";
# optional: extra helper arguments. The bundled GTK4 helper takes
# --layer-shell, presenting the prompt as a wlr-layer-shell overlay on
# wlroots compositors such as Sway or Hyprland:
# services.ucabled.helperExtraArgs = [ "--layer-shell" ];
}The module configures everything:
- a dedicated
ucabledsystem user and group - udev rule granting the
ucabledaccount rw to/dev/uhidvia an ACL (RUN+="... setfacl -m u:ucabled:rw /dev/uhid"), leaving the node's group alone boot.kernelModules = [ "uhid" ]hardware.bluetooth.enable = true(mkDefault, overridable; BLE adverts are cryptographically required by caBLE)- the system service
systemd.services.ucabled - the per-user agent
systemd.user.services.ucable-agent, enabled for every user - a D-Bus policy and the polkit action
org.ucabled.register-agent(BlueZ needs no polkit rule; the D-Bus policy already covers it)
Run nixos-rebuild switch, then log out and back in (or run
systemctl --user start ucable-agent) so the agent registers.
master is published to ucabled.cachix.org, so Nix substitutes the package
instead of building it. On NixOS:
nix.settings = {
extra-substituters = [ "https://ucabled.cachix.org" ];
extra-trusted-public-keys = [
"ucabled.cachix.org-1:iEDAC8e63hzBZ/HKtMibPzl6kvTnPblcFs4+C50XvwU="
];
};Elsewhere cachix use ucabled writes the same two settings into
$HOME/.config/nix/nix.conf.
CI fills the cache on every push to master. Only the artifacts of this
repository are cached — dependencies keep coming from cache.nixos.org — and
substitution needs the same commit and the same flake.lock, so a locally
modified tree is built as usual.
On a systemd distribution, build and run the installer:
cargo build --release
sudo ./install.sh # --prefix DIR to install elsewhereIt installs the three binaries, creates the ucabled service account, and
places the udev, D-Bus, polkit and systemd files, then enables the daemon and
the session agent. sudo ./install.sh --uninstall reverses it. Run
./install.sh --help for the overridable paths.
The equivalent manual steps, if you prefer:
cargo build --release
sudo install -Dm755 target/release/ucabled /usr/local/bin/ucabled
sudo install -Dm755 target/release/ucable-agent /usr/local/bin/ucable-agent
sudo install -Dm755 target/release/ucable-agent-helper /usr/local/bin/ucable-agent-helper
# translations for the GTK4 helper (needs gettext's msgfmt)
for po in po/*.po; do
lang=$(basename "$po" .po)
sudo install -d /usr/local/share/locale/$lang/LC_MESSAGES
sudo msgfmt "$po" -o /usr/local/share/locale/$lang/LC_MESSAGES/ucable-agent-helper-gtk.mo
done
# dedicated service account, kernel module and udev rule
sudo groupadd --system ucabled
sudo useradd --system --gid ucabled --no-create-home ucabled
sudo modprobe uhid
sudo cp dist/90-ucabled.rules /etc/udev/rules.d/
sudo udevadm control --reload
# system D-Bus policy + polkit action
sudo cp dist/org.ucabled.conf /etc/dbus-1/system.d/
sudo cp dist/org.ucabled.policy /usr/share/polkit-1/actions/
# system daemon
sudo cp dist/ucabled.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now ucabled
# per-user session agent
mkdir -p ~/.config/systemd/user
cp dist/ucable-agent.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now ucable-agentReload the udev rule (sudo udevadm trigger --subsystem-match=misc) and log
out/in once. Adjust the ExecStart paths if the binaries live elsewhere.
Just use WebAuthn in Firefox (register or sign in). ucabled pops up a small always-on-top window with a QR code; scan it with your phone and follow the prompt (Face ID / fingerprint), and the browser finishes the operation. Close the window or click Cancel to abort.
- The window is shown by the
ucable-agentsession agent. If it is not running, or you are not the active local session, the request fails with a CTAP timeout instead of starting an invisible transaction. Running the daemon with--no-uiprints the QR code on a controlling terminal instead. - Cancel/timeout/disconnect are mapped back to Firefox as the proper CTAP error codes.
ucable-agent draws nothing itself: for each prompt it spawns a helper
executable and speaks to it over argv, stdin and the exit status (see
docs/helper-contract.md). The bundled ucable-agent-helper is a native GTK4
program: as a normal window it carries a header bar with a close button; with
--layer-shell it becomes a wlr-layer-shell overlay on wlroots compositors
such as Sway — a bare, rounded card with no chrome, dismissed by Esc or the
agent. Its UI strings are localized with gettext (the Nix package installs the
catalogs). Build it with --features adwaita for GNOME-native window chrome.
Any other contract-v1 helper can replace it: pick one with UCABLED_HELPER (an
absolute path or a bare command name), or with services.ucabled.helper on
NixOS; extra helper flags go in services.ucabled.helperExtraArgs (for example
[ "--layer-shell" ]).
OpenSSH's FIDO sk key types work as well, without a browser: ssh and
ssh-keygen talk to the virtual device directly, so a ssh-keygen -t ecdsa-sk
or ssh-keygen -Y sign in the active session pops the same QR window, and the
phone asks for its passcode/biometric (user verification) before answering.
# Create a key. Phones have no non-resident mode: every key is a
# discoverable passkey on the phone; the key handle ssh needs to
# select it lives in the private key file.
ssh-keygen -t ecdsa-sk -f ~/.ssh/id_ecdsa_sk
# -O resident is accepted but changes little on a phone, since every
# passkey is already discoverable there.
ssh-keygen -t ecdsa-sk -O resident -f ~/.ssh/id_ecdsa_resident
# Install the public key on a server and log in.
ssh-copy-id -i ~/.ssh/id_ecdsa_sk.pub you@example.com
ssh -i ~/.ssh/id_ecdsa_sk you@example.com
# Sign and verify a file (no SSH server involved).
ssh-keygen -Y sign -f ~/.ssh/id_ecdsa_sk -n file ./file # writes ./file.sig
printf '%s %s\n' "$USER" "$(cat ~/.ssh/id_ecdsa_sk.pub)" > allowed_signers
ssh-keygen -Y verify -f allowed_signers -I "$USER" -n file -s ./file.sig < ./fileEach of these opens the QR window; run it in the active session. Re-enrolling over a passkey that already exists on the phone asks before overwriting it.
- Use
ecdsa-sk.ed25519-skis not supported: phone passkey providers only sign ES256, so the phone closes the session when asked for an Ed25519 credential. - Phone passkey providers ignore
rk=falseand always create discoverable credentials, so there is no true non-resident key here;-O residentchanges little beyond the enrollment flow. - Downloading resident keys with
ssh-keygen -Kis not supported: it uses the CTAP credential-management commands, which phone passkey providers do not expose. Keep the private key file written at enrollment (or enroll again). - The SSH client must run on this machine in the active local session; the token
cannot be used from a remote session, and there is no
ssh-agentintegration.
- The host keeps no key material: the private key and every FIDO operation stay on the phone. The daemon is a relay; the tunnel carries end-to-end encrypted CTAP (Noise KNpsk0, AES-256-GCM), so the tunnel server sees only ciphertext, and the BLE advert is a cryptographic proximity proof.
/dev/uhidis granted only to the dedicateducabledservice account./dev/uhidlets a process create arbitrary virtual HID devices (including a keyboard), so it is not given to the human user; Firefox only needs the resulting hidraw node, which still gets uaccess from systemd's FIDO rules. The daemon runs unprivileged in a systemd sandbox. If the node is nonetheless writable by another account (e.g. a stale udevuaccesstag), the daemon logs a prominent warning at startup instead of changing permissions itself.- Only the active local session may show the QR window. The session agent
registers as a D-Bus agent, and the daemon authorizes the registration
through polkit (
allow_active=yes), re-checking on every prompt. SSH and remote sessions are refused by construction. - The transaction secret (in the QR code) travels from the daemon to the agent as a D-Bus unicast message and then to the helper over a pipe — never via a command line or the journal. It is a short-lived, single-transaction bearer token: treat the screen as sensitive until the transaction ends.
| Symptom | Fix |
|---|---|
| No QR window appears | The ucable-agent agent is not registered: systemctl --user status ucable-agent and journalctl --user -u ucable-agent -f. Only the active local session may show the window |
| The browser does not see the device | ls /dev/hidraw*; systemctl status ucabled; check udevadm info for ID_FIDO_TOKEN=1 |
| Transactions keep failing | Make sure Bluetooth is on (the system will not enable it for you); journalctl -u ucabled -f |
| Phone cannot scan the QR code | Make sure the log does not say Bluetooth adapter is powered off and that Bluetooth works on the phone |
| Firefox says "Multiple devices found" (another security key is plugged in) | Touch that security key to use it, or click Use phone in the ucabled window. Firefox itself can only pick an authenticator by touch, so the phone is chosen through ucabled's own prompt |
Logs contain only command bytes and lengths, never raw CBOR payloads.
- Only iOS has been tested (iCloud Keychain, Google Password Manager and
Strongbox); Android is untested. State-assisted linking
("remember this computer", scan-free reconnect) is not supported on iOS and
is shelved; see
docs/linking.md. - Only the active local session gets a window; a pure TTY or SSH prompt is out of scope.
- With Firefox, RPs always see
transports: ["usb"]: that is a hardcode in Firefox's Linux CTAP backend (seedocs/plan.md§5.3). It is cosmetic and does not affect usage. The reverse — us forwarding that hint to the phone — is stripped: the daemon drops the advisorytransportsfield fromallowList/excludeListdescriptors before relaying, so phones that reject a hybrid credential whose hint does not mention hybrid still find it. - Of OpenSSH's FIDO key types only
ecdsa-skworks;ed25519-skis rejected because phone passkey providers do not implement the Ed25519 algorithm. - No local PIN/UV and no attestation trust decisions — the phone does all of that.
- With another authenticator (e.g. a physical security key) plugged in,
Firefox lets the user pick only by touching a physical key, so ucabled shows
its own "Use phone" window. Run with
--no-uiand it cannot participate in the selection and stands down.
nix develop # toolchain (use nix-shell if you have no cargo)
cargo test # unit tests + local-relay end-to-end tests
cargo clippy --all-targets
nix build .#ucabled
nix flake checkThe bundled GTK4 helper is part of the default build, so a plain
cargo build needs GTK4 development headers (plus the wlr-layer-shell bindings)
and gettext; the Nix dev shell provides them. Add --features adwaita for
GNOME-native window chrome — the Nix package builds without libadwaita to stay
desktop-neutral.
Helper tools in the repo (not shipped in the package):
src/bin/ucabled-spike.rs: manual single-transaction experiment (--advert-hexskips the BLE scan)src/bin/mock-phone.rs,tests/e2e.rs: mock phone and local tunnel relaysrc/bin/hidraw-probe.rs: kernel HID path diagnosticssrc/bin/cable-advert-probe.rs: show a caBLE QR offering the BLE data channel and report whether the phone advertised an L2CAP PSM
Requirements and design: docs/plan.md; system service / UI agent design:
docs/system-service.md; D-Bus async design: docs/async-dbus.md; tunnel
server rationale: docs/tunnel-server.md; BLE data channel: docs/gatt-data-channel.md
and docs/l2cap-channel.md; related projects: docs/prior-art.md; linking
design: docs/linking.md. If you keep your SSH key as a passkey on the phone,
see docs/ssh-git-push.md (Chinese) for avoiding repeated QR scans on
git push (SSH connection multiplexing + git-lfs).
The optional CTAP 2.3 BLE data channel (L2CAP CoC) is experimental and off by
default: build with --features l2cap and set UCABLED_BLE_CHANNEL=1. iPhones
do not offer it (verified); it targets Android.
GPL-3.0-or-later; see COPYING.