Skip to content
fuzy112Public

About

Phone Passkey Bridge

Topics

Resources

Security policy

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

ucabled — Phone Passkey Bridge

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.

Status

  • 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.

Install (NixOS, recommended)

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 ucabled system user and group
  • udev rule granting the ucabled account rw to /dev/uhid via 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.

Binary cache

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.

Install (other distros)

On a systemd distribution, build and run the installer:

cargo build --release
sudo ./install.sh            # --prefix DIR to install elsewhere

It 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-agent

Reload the udev rule (sudo udevadm trigger --subsystem-match=misc) and log out/in once. Adjust the ExecStart paths if the binaries live elsewhere.

Usage

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-agent session 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-ui prints the QR code on a controlling terminal instead.
  • Cancel/timeout/disconnect are mapped back to Firefox as the proper CTAP error codes.

UI helpers

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" ]).

SSH security keys

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 < ./file

Each 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-sk is 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=false and always create discoverable credentials, so there is no true non-resident key here; -O resident changes little beyond the enrollment flow.
  • Downloading resident keys with ssh-keygen -K is 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-agent integration.

Security

  • 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/uhid is granted only to the dedicated ucabled service account. /dev/uhid lets 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 udev uaccess tag), 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.

Troubleshooting

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.

Known limitations

  • 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 (see docs/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 advisory transports field from allowList/excludeList descriptors 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-sk works; ed25519-sk is 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-ui and it cannot participate in the selection and stands down.

Development

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 check

The 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-hex skips the BLE scan)
  • src/bin/mock-phone.rs, tests/e2e.rs: mock phone and local tunnel relay
  • src/bin/hidraw-probe.rs: kernel HID path diagnostics
  • src/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.

License

GPL-3.0-or-later; see COPYING.

About

Phone Passkey Bridge

Topics

Resources

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages