Skip to content

Custom UI Frontend

No Name edited this page Jun 18, 2026 · 6 revisions

Installation of Custom GUI Binaries

This is outdated now.

First, the binaries must be installed in /usr/lib/passkeyd/, and ensure that the binaries have write access only for the root user.

Next, navigate to /etc/passkeyd.conf and configure the following:

  • FRONT_ENROLL=your_binary_name
  • FRONT_SELECT=your_binary_name
  • FRONT_SELECTION=your_binary_name

Note that these options accept the name of the binary, not the full path, so be careful when specifying them.

  • FRONT_SELECT is invoked to select a passkey, and a password window must also be included here.
  • FRONT_ENROLL is invoked to request user permissions for creating a passkey.
  • FRONT_SELECTION is invoked to verify the presence of the user.

For GUI reference, refer to Issue #1 or Issue #2 for the relevant GUI dialogs.

For theming, place the theme file under /usr/share/passkeyd/your_custom_gui_name.conf. The format is up to you, as it’s for your own reference.

For a simple implementation example, scroll to the bottom of this wiki page.

Communication Protocol

The communication between passkeyd and its UI is done through standard input/output (stdin/stdout) and exit codes. Since the GUI runs with user privileges, this simplifies communication with the higher-privilege passkeyd.

UI Invocation

The Rust code posted below is a UI spawner. The GUI is spawned under (with the same privileges as) a configured user, whose UID is defined in /etc/passkeyd.conf. It executes the GUI binaries via systemd-run.

// Generic `State` depends on the type of UI being called. 

pub fn spawn_ui<State>(config: &Config, ui: UI, state: State) -> Child
where
    State: Serialize,
{
    let mut state_buffer = CborVec::new();
    let cbor = cbor_serialize_to(&state, &mut state_buffer).unwrap();
    debug!("Spawning UI");

    let mut command = std::process::Command::new("systemd-run")
        .arg(format!("--machine={}@", config.gui_uid))
        .arg("--user")
        .arg("--collect")
        .arg("--wait")
        .arg("--quiet")
        .arg("--pipe")
        .arg(ui.as_str_path())
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .spawn()
        .expect("Failed to spawn UI. Are you root?");

    let mut stdin = command.stdin.take().expect("Failed to get stdin");
    stdin
        .write_all(&state_buffer.0[..cbor])
        .expect("Failed to write into pipe");

    command
}

Data Structures

For the CTAP command authenticatorMakeCredential, the GUI receives the following state/structure via standard input in CBOR encoding.

It is extremely easy to deserialize in dynamic languages since they don’t require strict type definitions. However, in type-constrained languages, you will need to define these structures:

#[derive(Serialize)]
pub struct AuthorizationUI<'a> {
    pub rp: &'a PublicKeyCredentialRpEntity,
    pub other_ui: &'a OtherUI,
}

#[derive(Serialize, Deserialize)]
pub struct OtherUI {
    pub user: PublicKeyCredentialUserEntity,
    pub site_icon: Option<bytes::Bytes>,
    pub user_icon: Option<bytes::Bytes>,
}

Note: You can use existing libraries for these definitions (e.g., if your language has a package for CTAP2 types). These follow the standard, so definitions are compatible.

When a user agrees, the GUI must exit with a status code of 0. If the user declines or an error occurs, return any non-zero exit code.


For the CTAP command authenticatorGetCredential,the GUI receives the following state/structure via standard input in CBOR encoding.

#[derive(Serialize)]
pub struct UI<'a> {
    pub rp: &'a PublicKeyCredentialRpEntity,
    pub other_uis: Vec<&'a OtherUI>,
}

The selection of the user and the password entry must happen within the same binary. Once a user makes a selection, you must write the selected OtherUI index (as a native-size integer) and the password string (as bytes) to standard out.

Output Format

To signal the start of the transmission, send the Start of Text (STX) ASCII character (0x02) to standard output.

Note: After the 0x02 signal, do not send any other data to stdout besides the expected bytes. Doing so will crash passkeyd, as it does not handle unexpected bytes.

Byte Order:

  1. STX: 0x02.
  2. Index: Convert the selected OtherUI index into a native-size unsigned integer (little-endian).
  3. Password: A UTF-8 encoded string.
  4. ETX: 0x03 (End of Text).

After, gui can exit with a status code of 0. If the user declines or an error occurs, return any non-zero exit code.

passkeyd-select Implementation:

fn write_output(authorized_index: usize, passphrase: &mut str) -> std::io::Result<()> {
    let mut stdout = std::io::stdout().lock();

    stdout.write_all(&[0x02])?; // Start of Text
    stdout.write_all(&authorized_index.to_le_bytes())?;
    stdout.write_all(passphrase.as_bytes())?;
    stdout.write_all(&[0x03])?; // End of Text
    stdout.flush()?;

    passphrase.zeroize();
    Ok(())
}

Passkeyd side reading:

let data_slice = if result.status.success()
    && let Some(start_pos) = stdout.iter().position(|&x| x == 0x02)
{
    if let Some(end_offset) = stdout[start_pos + 1..].iter().position(|&x| x == 0x03) {
        let end_pos = start_pos + 1 + end_offset;
        &stdout[start_pos + 1..end_pos]
    } else {
        anyhow::bail!(CtapStatus::OperationDenied)
    }
} else {
    info!("The request was denied");
    anyhow::bail!(CtapStatus::KeepaliveCancel)
};

let authorized_idx = usize::from_le_bytes(data_slice[..size_of::<usize>()].try_into().unwrap());
let password = String::from_utf8_lossy(&data_slice[size_of::<usize>()..]).to_string();

Authentication

The password is re-checked by passkeyd via PAM. If the password fails, passkeyd will not ask the GUI to try again. it will assume the request was denied by the user. That's why passkeyd-select validates it in the GUI before sending.

Passkeyd side reading:

let mut client = Client::with_password("system-auth").expect("Failed to init PAM client!");
client
    .conversation_mut()
    .set_credentials(login_user, password);
// Entering the wrong password more than the configured 'deny' attempts will lock your account. Even with the correct password, it will still report as invalid.
// To unlock the account, use the command: `faillock --user <username> --reset`, or wait for the configured lock time in PAM, which is usually around 600 seconds (10 minutes).
if client.authenticate().is_err() {
    // If the retry count exceeds three, the client must
    // assume the password is valid and return it,
    // so the daemon can verify the password. If the password is wrong,
    // it is clear that the retry limit has been exceeded.
    // The client is considered untrusted, and the daemon,
    // being the trusted entity, must validate
    // anything sensitive carefully.
    anyhow::bail!(CtapStatus::UvBlocked)
}

Custom Passkeyd Frontend Implementation Example

Example of a custom passkeyd-enroll, CLI-based, written in Node.js:

#!/usr/bin/env node
import { decode } from "cbor2";

const buff = await new Promise((resolve) => {
  process.stdin.once("data", resolve);

  // Simulate data write to stdin (this will be done by passkeyd instead).
  process.stdin.emit("data", get_test_cbor_encoded_buffer());
});

const dataReceivedFromPasskeyd = decode(buff);
console.log(
  `Passkey Request - Website: "${dataReceivedFromPasskeyd.rp.name}" (domain: ${dataReceivedFromPasskeyd.rp.id})`,
);
process.stdout.write("\n\nAuthorize(y/n) > ");
const isYes = String(
  await new Promise((resolve) => process.stdin.once("data", resolve)),
).startsWith("y");

process.exit(isYes ? 0 : 1);

// Simulate input done by passkeyd
function get_test_cbor_encoded_buffer() {
  return new Uint8Array([
    162, 98, 114, 112, 162, 98, 105, 100, 106, 103, 105, 116, 104, 117, 98, 46,
    99, 111, 109, 100, 110, 97, 109, 101, 102, 71, 105, 116, 104, 117, 98, 104,
    111, 116, 104, 101, 114, 95, 117, 105, 163, 100, 117, 115, 101, 114, 163,
    98, 105, 100, 88, 64, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
    1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
    1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 100, 110, 97,
    109, 101, 102, 71, 105, 116, 104, 117, 98, 107, 100, 105, 115, 112, 108, 97,
    121, 78, 97, 109, 101, 103, 115, 112, 97, 99, 101, 57, 48, 105, 115, 105,
    116, 101, 95, 105, 99, 111, 110, 246, 105, 117, 115, 101, 114, 95, 105, 99,
    111, 110, 246,
  ]);
}

Example of a custom passkeyd-selec, CLI-based, written in Node.js:

#!/usr/bin/env node
import { decode } from "cbor2";

const buff = await new Promise((resolve) => {
  process.stdin.once("data", resolve);

  // Simulate data write to stdin (this will be done by passkeyd instead).
  process.stdin.emit("data",  get_test_cbor_encoded_buffer());
});

const dataReceivedFromPasskeyd = decode(buff);
console.log(
  `Passkey Request - Website: "${dataReceivedFromPasskeyd.rp.name}" (domain: ${dataReceivedFromPasskeyd.rp.id})`
);
console.log("Type a passkey you want to use");

dataReceivedFromPasskeyd.other_uis.forEach((otherUi, index) => {
  console.log(
    `Index: ${index} Display Name: ${otherUi.user.displayName} Username: ${otherUi.user.name}`
  );
});

process.stdout.write("\n\nWrite an index to select > ");
const selectedIndex = parseInt(
  await new Promise((resolve) => process.stdin.once("data", resolve))
);

if (isNaN(selectedIndex) || selectedIndex < 0 || selectedIndex >= dataReceivedFromPasskeyd.other_uis.length) {
  console.log("Bad index");
  process.exit(1);
}

console.log(
  `Selected Account: ${selectedIndex} Display Name: ${dataReceivedFromPasskeyd.other_uis[selectedIndex].user.displayName} Username: ${dataReceivedFromPasskeyd.other_uis[selectedIndex].user.name}`
);

process.stdout.write("\n\nEnter your password > ");
const password = await new Promise((resolve) => process.stdin.once("data", resolve));

process.stdout.write(Buffer.from([0x02])); // Start of the text

const is64Bit = process.arch === "x64" || process.arch === "arm64";
const nativeSize = is64Bit ? 8 : 4; // 8 bytes for 64-bit systems, 4 bytes for 32-bit systems

const buffer = Buffer.alloc(nativeSize);
if (is64Bit) {
  buffer.writeBigInt64LE(BigInt(selectedIndex), 0); // 64-bit integer for 64-bit systems
} else {
  buffer.writeInt32LE(selectedIndex, 0); // 32-bit integer for 32-bit systems
}

process.stdout.write(buffer);
process.stdout.write(Buffer.from(password, "utf8"));
process.stdout.write(Buffer.from([0x03])); // End of the text

process.exit(0); //exit with status code '0'


// Simulate input done by passkeyd
function get_test_cbor_encoded_buffer() {
  return new Uint8Array([
    162, 98, 114, 112, 162, 98, 105, 100, 106, 103, 105, 116, 104, 117, 98, 46,
    99, 111, 109, 100, 110, 97, 109, 101, 102, 71, 105, 116, 104, 117, 98, 105,
    111, 116, 104, 101, 114, 95, 117, 105, 115, 133, 163, 100, 117, 115, 101,
    114, 163, 98, 105, 100, 88, 64, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
    1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
    1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 100,
    110, 97, 109, 101, 102, 71, 105, 116, 104, 117, 98, 107, 100, 105, 115, 112,
    108, 97, 121, 78, 97, 109, 101, 101, 97, 99, 99, 45, 48, 105, 115, 105, 116,
    101, 95, 105, 99, 111, 110, 246, 105, 117, 115, 101, 114, 95, 105, 99, 111,
    110, 246, 163, 100, 117, 115, 101, 114, 163, 98, 105, 100, 88, 64, 1, 1, 1,
    1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
    1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
    1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 100, 110, 97, 109, 101, 102, 71, 105, 116,
    104, 117, 98, 107, 100, 105, 115, 112, 108, 97, 121, 78, 97, 109, 101, 101,
    97, 99, 99, 45, 49, 105, 115, 105, 116, 101, 95, 105, 99, 111, 110, 246,
    105, 117, 115, 101, 114, 95, 105, 99, 111, 110, 246, 163, 100, 117, 115,
    101, 114, 163, 98, 105, 100, 88, 64, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
    1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
    1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
    1, 100, 110, 97, 109, 101, 102, 71, 105, 116, 104, 117, 98, 107, 100, 105,
    115, 112, 108, 97, 121, 78, 97, 109, 101, 101, 97, 99, 99, 45, 50, 105, 115,
    105, 116, 101, 95, 105, 99, 111, 110, 246, 105, 117, 115, 101, 114, 95, 105,
    99, 111, 110, 246, 163, 100, 117, 115, 101, 114, 163, 98, 105, 100, 88, 64,
    1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
    1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
    1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 100, 110, 97, 109, 101, 102, 71,
    105, 116, 104, 117, 98, 107, 100, 105, 115, 112, 108, 97, 121, 78, 97, 109,
    101, 101, 97, 99, 99, 45, 51, 105, 115, 105, 116, 101, 95, 105, 99, 111,
    110, 246, 105, 117, 115, 101, 114, 95, 105, 99, 111, 110, 246, 163, 100,
    117, 115, 101, 114, 163, 98, 105, 100, 88, 64, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
    1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
    1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
    1, 1, 1, 1, 100, 110, 97, 109, 101, 102, 71, 105, 116, 104, 117, 98, 107,
    100, 105, 115, 112, 108, 97, 121, 78, 97, 109, 101, 101, 97, 99, 99, 45, 52,
    105, 115, 105, 116, 101, 95, 105, 99, 111, 110, 246, 105, 117, 115, 101,
    114, 95, 105, 99, 111, 110, 246,
  ]);
}