Files
fluxer-desktop-patched/fluxer_desktop/native/webauthn/src/lib.rs
T
brenden 682afacd30 Add native self-hosted instance connection to fluxer_desktop
Trimmed monorepo checkout (fluxer_desktop + packages/voice_engine_v2 +
tools/ci) with a "Connect to a Different Server" menu item and popout
that lets the desktop app switch to any self-hosted Fluxer instance,
plus fixes for well-known discovery on single-domain self-hosted
deployments and a false-positive ERR_ABORTED on same-origin client
redirects during the switch. Defaults to chat.fluxr.chat and uses an
isolated userData directory from the official build.
2026-07-01 18:22:43 -04:00

684 lines
21 KiB
Rust

#![allow(clippy::too_many_lines)]
// SPDX-License-Identifier: AGPL-3.0-or-later
use napi::Task;
use napi::bindgen_prelude::{
AsyncTask, Buffer, Env, Error, JsObjectValue, Object, Result, Status, Unknown,
};
use napi::{JsValue, ValueType};
use napi_derive::napi;
mod common;
use common::{
CreateInput, CreateResult, DescriptorInput, GetInput, GetResult, attachment_from_transport,
};
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(target_os = "windows")]
mod windows;
#[cfg(target_os = "macos")]
const BACKEND_NAME: &str = "macos-authenticationservices";
#[cfg(target_os = "windows")]
const BACKEND_NAME: &str = "windows-webauthn";
#[cfg(target_os = "linux")]
const BACKEND_NAME: &str = "linux-libfido2";
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
const BACKEND_NAME: &str = "unavailable";
#[cfg(target_os = "macos")]
const BACKEND_REASON: &str = "macOS WebAuthn backend (AuthenticationServices). \
Requires macOS 12.0+ (ASAuthorizationPlatformPublicKeyCredentialProvider, \
introduced in Monterey). On older macOS the bundled \
ASAuthorizationPlatform/SecurityKey provider classes are not present and \
`isSupported()` returns false.";
#[cfg(target_os = "windows")]
const BACKEND_REASON: &str = "Windows WebAuthn broker backend (webauthn.dll). \
Requires Windows 10 build 18362 (May 2019 / version 1903) or newer; \
webauthn.dll is delay-loaded via /DELAYLOAD, so the addon still loads on \
older builds but `isSupported()` returns false there.";
#[cfg(target_os = "linux")]
const BACKEND_REASON: &str = "Linux WebAuthn backend (libfido2). Requires \
libfido2 >= 1.5.0 (HID transport, U2F fallback disabled) and a CTAP \
authenticator on USB-HID; `isSupported()` returns false when no \
authenticator is enumerated.";
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
const BACKEND_REASON: &str = "no WebAuthn backend is compiled in for this platform";
const TARGET_PLATFORM: &str = if cfg!(target_os = "macos") {
"darwin"
} else if cfg!(target_os = "windows") {
"win32"
} else if cfg!(target_os = "linux") {
"linux"
} else {
"unknown"
};
const TARGET_ARCH: &str = if cfg!(target_arch = "x86_64") {
"x64"
} else if cfg!(target_arch = "aarch64") {
"arm64"
} else {
"unknown"
};
fn target_string() -> String {
format!("{TARGET_PLATFORM}/{TARGET_ARCH}")
}
fn platform_broker_available() -> bool {
#[cfg(target_os = "windows")]
{
windows::api_version() > 0
}
#[cfg(target_os = "linux")]
{
linux::is_supported()
}
#[cfg(target_os = "macos")]
{
macos::is_supported()
}
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
{
false
}
}
fn platform_authenticator_available() -> bool {
#[cfg(target_os = "windows")]
{
windows::is_user_verifying_platform_authenticator_available()
}
#[cfg(not(target_os = "windows"))]
{
false
}
}
fn ceremonies_implemented() -> bool {
cfg!(any(
target_os = "macos",
target_os = "windows",
target_os = "linux"
))
}
fn is_supported_native() -> bool {
ceremonies_implemented() && platform_broker_available()
}
fn api_version() -> u32 {
#[cfg(target_os = "windows")]
{
windows::api_version()
}
#[cfg(not(target_os = "windows"))]
{
0
}
}
fn backend_reason() -> String {
#[cfg(target_os = "linux")]
{
format!("{} {}", BACKEND_REASON, linux::support_diagnostics())
}
#[cfg(not(target_os = "linux"))]
{
BACKEND_REASON.to_owned()
}
}
#[napi(object, js_name = "WebAuthnBackendInfo")]
pub struct WebAuthnBackendInfoJs {
pub target: String,
pub backend: String,
#[napi(js_name = "nativeLoaded")]
pub native_loaded: bool,
#[napi(js_name = "ceremoniesImplemented")]
pub ceremonies_implemented: bool,
#[napi(js_name = "platformBrokerAvailable")]
pub platform_broker_available: bool,
#[napi(js_name = "platformAuthenticatorAvailable")]
pub platform_authenticator_available: bool,
pub supported: bool,
#[napi(js_name = "apiVersion")]
pub api_version: u32,
pub reason: String,
}
#[napi(js_name = "getBackendInfo")]
pub fn get_backend_info() -> WebAuthnBackendInfoJs {
WebAuthnBackendInfoJs {
target: target_string(),
backend: BACKEND_NAME.to_owned(),
native_loaded: true,
ceremonies_implemented: ceremonies_implemented(),
platform_broker_available: platform_broker_available(),
platform_authenticator_available: platform_authenticator_available(),
supported: is_supported_native(),
api_version: api_version(),
reason: backend_reason(),
}
}
pub struct IsSupportedTask;
impl Task for IsSupportedTask {
type Output = bool;
type JsValue = bool;
fn compute(&mut self) -> Result<Self::Output> {
Ok(is_supported_native())
}
fn resolve(&mut self, _env: Env, output: Self::Output) -> Result<Self::JsValue> {
Ok(output)
}
}
#[napi(js_name = "isSupported")]
pub fn is_supported() -> AsyncTask<IsSupportedTask> {
AsyncTask::new(IsSupportedTask)
}
#[napi(object)]
pub struct CreateCeremonyResultJs {
#[napi(js_name = "rawId")]
pub raw_id: Buffer,
#[napi(js_name = "attestationObject")]
pub attestation_object: Buffer,
#[napi(js_name = "clientDataJSON")]
pub client_data_json: Buffer,
#[napi(js_name = "authenticatorAttachment")]
pub authenticator_attachment: Option<String>,
}
#[napi(object)]
pub struct GetCeremonyResultJs {
#[napi(js_name = "rawId")]
pub raw_id: Buffer,
#[napi(js_name = "authenticatorData")]
pub authenticator_data: Buffer,
pub signature: Buffer,
#[napi(js_name = "userHandle")]
pub user_handle: Option<Buffer>,
#[napi(js_name = "clientDataJSON")]
pub client_data_json: Buffer,
#[napi(js_name = "authenticatorAttachment")]
pub authenticator_attachment: Option<String>,
}
pub struct CreateTask {
input: CreateInput,
}
impl Task for CreateTask {
type Output = CreateResult;
type JsValue = CreateCeremonyResultJs;
fn compute(&mut self) -> Result<Self::Output> {
run_create(&mut self.input)
}
fn resolve(&mut self, _env: Env, output: Self::Output) -> Result<Self::JsValue> {
Ok(CreateCeremonyResultJs {
raw_id: Buffer::from(output.raw_id),
attestation_object: Buffer::from(output.attestation_object),
client_data_json: Buffer::from(output.client_data_json),
authenticator_attachment: attachment_from_transport(output.used_transport)
.map(str::to_owned),
})
}
}
pub struct GetTask {
input: GetInput,
}
impl Task for GetTask {
type Output = GetResult;
type JsValue = GetCeremonyResultJs;
fn compute(&mut self) -> Result<Self::Output> {
run_get(&mut self.input)
}
fn resolve(&mut self, _env: Env, output: Self::Output) -> Result<Self::JsValue> {
Ok(GetCeremonyResultJs {
raw_id: Buffer::from(output.raw_id),
authenticator_data: Buffer::from(output.authenticator_data),
signature: Buffer::from(output.signature),
user_handle: output.user_handle.map(Buffer::from),
client_data_json: Buffer::from(output.client_data_json),
authenticator_attachment: attachment_from_transport(output.used_transport)
.map(str::to_owned),
})
}
}
fn run_create(input: &mut CreateInput) -> Result<CreateResult> {
#[cfg(target_os = "windows")]
{
windows::make_credential(input)
}
#[cfg(target_os = "linux")]
{
linux::make_credential(input)
}
#[cfg(target_os = "macos")]
{
macos::make_credential(input)
}
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
{
let _ = input;
Err(common::ceremony_error(
common::CREATE_PREFIX,
"UnknownPlatform",
))
}
}
fn run_get(input: &mut GetInput) -> Result<GetResult> {
#[cfg(target_os = "windows")]
{
windows::get_assertion(input)
}
#[cfg(target_os = "linux")]
{
linux::get_assertion(input)
}
#[cfg(target_os = "macos")]
{
macos::get_assertion(input)
}
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
{
let _ = input;
Err(common::ceremony_error(
common::GET_PREFIX,
"UnknownPlatform",
))
}
}
#[napi(js_name = "create")]
pub fn create(options: Object) -> Result<AsyncTask<CreateTask>> {
let input = parse_create_input(&options)?;
Ok(AsyncTask::new(CreateTask { input }))
}
#[napi(js_name = "get")]
pub fn get(options: Object) -> Result<AsyncTask<GetTask>> {
let input = parse_get_input(&options)?;
Ok(AsyncTask::new(GetTask { input }))
}
fn invalid_create_options() -> Error {
Error::new(
Status::InvalidArg,
"invalid WebAuthn registration options".to_owned(),
)
}
fn invalid_get_options() -> Error {
Error::new(
Status::InvalidArg,
"invalid WebAuthn assertion options".to_owned(),
)
}
fn parse_create_input(object: &Object) -> Result<CreateInput> {
let map_err = |_e: Error| invalid_create_options();
Ok(CreateInput {
rp_id: required_string(object, "rpId").map_err(map_err)?,
rp_name: required_string(object, "rpName").map_err(map_err)?,
challenge: required_buffer(object, "challenge").map_err(map_err)?,
user_id: required_buffer(object, "userId").map_err(map_err)?,
user_name: required_string(object, "userName").map_err(map_err)?,
user_display_name: required_string(object, "userDisplayName").map_err(map_err)?,
client_data_json: required_buffer(object, "clientDataJSON").map_err(map_err)?,
client_data_hash: required_buffer(object, "clientDataHash").map_err(map_err)?,
pub_key_algs: required_alg_array(object).map_err(map_err)?,
exclude_credentials: optional_descriptor_array(object, "excludeCredentials")
.map_err(map_err)?,
timeout_ms: optional_u32(object, "timeout", 0).map_err(map_err)?,
authenticator_attachment: optional_u32(
object,
"authenticatorAttachment",
default_attachment(),
)
.map_err(map_err)?,
user_verification: optional_u32(object, "userVerification", default_user_verification())
.map_err(map_err)?,
attestation: optional_u32(object, "attestation", default_attestation()).map_err(map_err)?,
enterprise_attestation: optional_u32(
object,
"enterpriseAttestation",
common::ENTERPRISE_NONE,
)
.map_err(map_err)?,
require_resident_key: optional_bool(object, "requireResidentKey", false)
.map_err(map_err)?,
prefer_resident_key: optional_bool(object, "preferResidentKey", false).map_err(map_err)?,
window_handle: optional_window_handle(object).map_err(map_err)?,
pin: optional_string(object, "pin").map_err(map_err)?,
})
}
fn parse_get_input(object: &Object) -> Result<GetInput> {
let map_err = |_e: Error| invalid_get_options();
Ok(GetInput {
rp_id: required_string(object, "rpId").map_err(map_err)?,
challenge: required_buffer(object, "challenge").map_err(map_err)?,
client_data_json: required_buffer(object, "clientDataJSON").map_err(map_err)?,
client_data_hash: required_buffer(object, "clientDataHash").map_err(map_err)?,
allow_credentials: optional_descriptor_array(object, "allowCredentials")
.map_err(map_err)?,
timeout_ms: optional_u32(object, "timeout", 0).map_err(map_err)?,
authenticator_attachment: optional_u32(
object,
"authenticatorAttachment",
default_attachment(),
)
.map_err(map_err)?,
user_verification: optional_u32(object, "userVerification", default_user_verification())
.map_err(map_err)?,
window_handle: optional_window_handle(object).map_err(map_err)?,
pin: optional_string(object, "pin").map_err(map_err)?,
})
}
fn optional_string(object: &Object, key: &str) -> Result<Option<String>> {
let Some(value) = read_unknown(object, key) else {
return Ok(None);
};
if is_nullish(&value) {
return Ok(None);
}
if value.get_type()? != ValueType::String {
return Err(Error::new(
Status::InvalidArg,
format!("{key} must be a string"),
));
}
let s: String = unsafe { value.cast::<String>()? };
if s.is_empty() { Ok(None) } else { Ok(Some(s)) }
}
fn optional_window_handle(object: &Object) -> Result<u64> {
let Some(value) = read_unknown(object, "windowHandle") else {
return Ok(0);
};
if is_nullish(&value) {
return Ok(0);
}
let bytes = read_buffer_bytes(&value, "windowHandle")?;
if bytes.len() > 8 {
return Err(Error::new(
Status::InvalidArg,
"windowHandle must be at most 8 bytes".to_owned(),
));
}
let mut padded = [0u8; 8];
padded[..bytes.len()].copy_from_slice(&bytes);
Ok(u64::from_le_bytes(padded))
}
#[cfg(target_os = "windows")]
fn default_attachment() -> u32 {
common::ATTACHMENT_ANY
}
#[cfg(target_os = "windows")]
fn default_user_verification() -> u32 {
common::USER_VERIFICATION_PREFERRED
}
#[cfg(target_os = "windows")]
fn default_attestation() -> u32 {
common::ATTESTATION_NONE
}
#[cfg(not(target_os = "windows"))]
fn default_attachment() -> u32 {
0
}
#[cfg(not(target_os = "windows"))]
fn default_user_verification() -> u32 {
0
}
#[cfg(not(target_os = "windows"))]
fn default_attestation() -> u32 {
0
}
fn read_unknown<'a>(object: &Object<'a>, key: &str) -> Option<Unknown<'a>> {
object.get::<Unknown>(key).ok().flatten()
}
fn is_nullish(v: &Unknown<'_>) -> bool {
match v.get_type() {
Ok(t) => t == ValueType::Null || t == ValueType::Undefined,
Err(_) => true,
}
}
fn required_string(object: &Object, key: &str) -> Result<String> {
let value = read_unknown(object, key)
.ok_or_else(|| Error::new(Status::InvalidArg, format!("missing {key}")))?;
if is_nullish(&value) {
return Err(Error::new(Status::InvalidArg, format!("missing {key}")));
}
if value.get_type()? != ValueType::String {
return Err(Error::new(
Status::InvalidArg,
format!("{key} must be a string"),
));
}
let s: String = unsafe { value.cast::<String>()? };
Ok(s)
}
fn optional_u32(object: &Object, key: &str, default: u32) -> Result<u32> {
let Some(value) = read_unknown(object, key) else {
return Ok(default);
};
if is_nullish(&value) {
return Ok(default);
}
if value.get_type()? != ValueType::Number {
return Err(Error::new(
Status::InvalidArg,
format!("{key} must be a number"),
));
}
let n: u32 = unsafe { value.cast::<u32>()? };
Ok(n)
}
fn optional_bool(object: &Object, key: &str, default: bool) -> Result<bool> {
let Some(value) = read_unknown(object, key) else {
return Ok(default);
};
if is_nullish(&value) {
return Ok(default);
}
if value.get_type()? != ValueType::Boolean {
return Err(Error::new(
Status::InvalidArg,
format!("{key} must be a boolean"),
));
}
let b: bool = unsafe { value.cast::<bool>()? };
Ok(b)
}
fn required_buffer(object: &Object, key: &str) -> Result<Vec<u8>> {
let value = read_unknown(object, key)
.ok_or_else(|| Error::new(Status::InvalidArg, format!("missing {key}")))?;
if is_nullish(&value) {
return Err(Error::new(Status::InvalidArg, format!("missing {key}")));
}
read_buffer_bytes(&value, key)
}
fn read_buffer_bytes(value: &Unknown<'_>, key: &str) -> Result<Vec<u8>> {
let raw = value.raw();
let raw_env = value.value().env;
let mut is_buffer = false;
let status = unsafe { napi::sys::napi_is_buffer(raw_env, raw, &mut is_buffer) };
if status != napi::sys::Status::napi_ok || !is_buffer {
return Err(Error::new(
Status::InvalidArg,
format!("{key} must be a Buffer"),
));
}
let mut data_ptr: *mut std::ffi::c_void = std::ptr::null_mut();
let mut len: usize = 0;
let status = unsafe { napi::sys::napi_get_buffer_info(raw_env, raw, &mut data_ptr, &mut len) };
if status != napi::sys::Status::napi_ok {
return Err(Error::new(
Status::InvalidArg,
format!("{key} buffer read failed"),
));
}
if len == 0 {
return Ok(Vec::new());
}
if data_ptr.is_null() {
return Err(Error::new(
Status::InvalidArg,
format!("{key} buffer pointer was null"),
));
}
let slice = unsafe { std::slice::from_raw_parts(data_ptr.cast::<u8>(), len) };
Ok(slice.to_vec())
}
fn required_alg_array(object: &Object) -> Result<Vec<i32>> {
let Some(value) = read_unknown(object, "pubKeyCredParams") else {
return Err(Error::new(
Status::InvalidArg,
"missing pubKeyCredParams".to_owned(),
));
};
if is_nullish(&value) {
return Err(Error::new(
Status::InvalidArg,
"missing pubKeyCredParams".to_owned(),
));
}
let raw = value.raw();
let raw_env = value.value().env;
let mut is_array = false;
let status = unsafe { napi::sys::napi_is_array(raw_env, raw, &mut is_array) };
if status != napi::sys::Status::napi_ok || !is_array {
return Err(Error::new(
Status::InvalidArg,
"pubKeyCredParams must be an array".to_owned(),
));
}
let arr: Object = unsafe { value.cast::<Object>()? };
let len = read_array_length(&arr)?;
if len == 0 {
return Err(Error::new(
Status::InvalidArg,
"pubKeyCredParams must not be empty".to_owned(),
));
}
let mut algs = Vec::with_capacity(len as usize);
for i in 0..len {
let mut elem_raw: napi::sys::napi_value = std::ptr::null_mut();
let status = unsafe { napi::sys::napi_get_element(raw_env, raw, i, &mut elem_raw) };
if status != napi::sys::Status::napi_ok {
return Err(Error::new(
Status::InvalidArg,
"pubKeyCredParams element read failed".to_owned(),
));
}
let elem_value: Unknown = unsafe { Unknown::from_raw_unchecked(raw_env, elem_raw) };
if elem_value.get_type()? != ValueType::Object {
return Err(Error::new(
Status::InvalidArg,
"pubKeyCredParams[] must be objects".to_owned(),
));
}
let elem: Object = unsafe { elem_value.cast::<Object>()? };
algs.push(optional_i32_field(&elem, "alg", 0)?);
}
Ok(algs)
}
fn optional_i32_field(object: &Object, key: &str, default: i32) -> Result<i32> {
let Some(value) = read_unknown(object, key) else {
return Ok(default);
};
if is_nullish(&value) {
return Ok(default);
}
if value.get_type()? != ValueType::Number {
return Err(Error::new(
Status::InvalidArg,
format!("{key} must be a number"),
));
}
let n: i32 = unsafe { value.cast::<i32>()? };
Ok(n)
}
fn optional_descriptor_array(object: &Object, key: &str) -> Result<Vec<DescriptorInput>> {
let Some(value) = read_unknown(object, key) else {
return Ok(Vec::new());
};
if is_nullish(&value) {
return Ok(Vec::new());
}
let raw = value.raw();
let raw_env = value.value().env;
let mut is_array = false;
let status = unsafe { napi::sys::napi_is_array(raw_env, raw, &mut is_array) };
if status != napi::sys::Status::napi_ok || !is_array {
return Err(Error::new(
Status::InvalidArg,
format!("{key} must be an array"),
));
}
let arr: Object = unsafe { value.cast::<Object>()? };
let len = read_array_length(&arr)?;
let mut out = Vec::with_capacity(len as usize);
for i in 0..len {
let mut elem_raw: napi::sys::napi_value = std::ptr::null_mut();
let status = unsafe { napi::sys::napi_get_element(raw_env, raw, i, &mut elem_raw) };
if status != napi::sys::Status::napi_ok {
return Err(Error::new(
Status::InvalidArg,
format!("{key}[] read failed"),
));
}
let elem_value: Unknown = unsafe { Unknown::from_raw_unchecked(raw_env, elem_raw) };
if elem_value.get_type()? != ValueType::Object {
return Err(Error::new(
Status::InvalidArg,
format!("{key}[] must be objects"),
));
}
let elem: Object = unsafe { elem_value.cast::<Object>()? };
let id = required_buffer(&elem, "id")?;
let transports = optional_u32(&elem, "transports", 0)?;
out.push(DescriptorInput { id, transports });
}
Ok(out)
}
fn read_array_length(obj: &Object) -> Result<u32> {
let length: u32 = obj.get_named_property::<u32>("length")?;
Ok(length)
}