// SPDX-License-Identifier: AGPL-3.0-or-later const {existsSync} = require('node:fs'); const {createHash} = require('node:crypto'); const {join, sep} = require('node:path'); const {createNativeLoadError, loadNativeBinding} = require('./loader-diagnostics.cjs'); const MODULE_NAME = '@fluxer/webauthn'; const SKIP_NATIVE_PROBE_ENV = 'FLUXER_WEBAUTHN_SKIP_NATIVE_PROBE'; const WEBAUTHN_AUTHENTICATOR_ATTACHMENT_ANY = 0; const WEBAUTHN_AUTHENTICATOR_ATTACHMENT_PLATFORM = 1; const WEBAUTHN_AUTHENTICATOR_ATTACHMENT_CROSS_PLATFORM = 2; const WEBAUTHN_USER_VERIFICATION_REQUIREMENT_REQUIRED = 1; const WEBAUTHN_USER_VERIFICATION_REQUIREMENT_PREFERRED = 2; const WEBAUTHN_USER_VERIFICATION_REQUIREMENT_DISCOURAGED = 3; const WEBAUTHN_ATTESTATION_CONVEYANCE_PREFERENCE_NONE = 1; const WEBAUTHN_ATTESTATION_CONVEYANCE_PREFERENCE_INDIRECT = 2; const WEBAUTHN_ATTESTATION_CONVEYANCE_PREFERENCE_DIRECT = 3; const WEBAUTHN_ENTERPRISE_ATTESTATION_NONE = 0; const WEBAUTHN_ENTERPRISE_ATTESTATION_VENDOR_FACILITATED = 1; const WEBAUTHN_CTAP_TRANSPORT_USB = 0x00000001; const WEBAUTHN_CTAP_TRANSPORT_NFC = 0x00000002; const WEBAUTHN_CTAP_TRANSPORT_BLE = 0x00000004; const WEBAUTHN_CTAP_TRANSPORT_INTERNAL = 0x00000010; const WEBAUTHN_CTAP_TRANSPORT_HYBRID = 0x00000020; const WEBAUTHN_CTAP_TRANSPORT_SMART_CARD = 0x00000040; function resolveNativeRoot() { const asarSegment = `${sep}app.asar${sep}`; if (!__dirname.includes(asarSegment)) return __dirname; const unpackedDir = __dirname.replace(asarSegment, `${sep}app.asar.unpacked${sep}`); return existsSync(unpackedDir) ? unpackedDir : __dirname; } function nativeFileName(platform = process.platform, arch = process.arch) { if (platform === 'darwin' && (arch === 'x64' || arch === 'arm64')) return `webauthn.darwin-${arch}.node`; if (platform === 'win32' && (arch === 'x64' || arch === 'arm64')) return `webauthn.win32-${arch}-msvc.node`; if (platform === 'linux' && (arch === 'x64' || arch === 'arm64')) return `webauthn.linux-${arch}-gnu.node`; return null; } let binding = null; let loadError = null; const fileName = nativeFileName(); if (fileName) { try { const nativeRoot = resolveNativeRoot(); const nativePath = join(nativeRoot, fileName); const loaded = loadNativeBinding({ moduleName: MODULE_NAME, nativePath, nativeRoot, packageDir: __dirname, skipNativeProbeEnv: SKIP_NATIVE_PROBE_ENV, }); binding = loaded.binding; loadError = loaded.loadError; if (loadError) throw loadError; } catch (error) { loadError = createNativeLoadError({ moduleName: MODULE_NAME, nativeRoot: resolveNativeRoot(), packageDir: __dirname, reason: 'native loader threw before binding load completed', cause: error, skipNativeProbeEnv: SKIP_NATIVE_PROBE_ENV, }); throw loadError; } } else { loadError = createNativeLoadError({ moduleName: MODULE_NAME, nativeRoot: resolveNativeRoot(), packageDir: __dirname, reason: `no native binary mapping for ${process.platform}/${process.arch}`, skipNativeProbeEnv: SKIP_NATIVE_PROBE_ENV, }); throw loadError; } function base64Url(buffer) { return Buffer.from(buffer).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } function normalizedOrigin(origin) { if (typeof origin !== 'string' || origin.length === 0) { throw new TypeError('@fluxer/webauthn requires an origin string'); } return new URL(origin).origin; } function relyingPartyId(options, origin) { if (typeof options.rpId === 'string' && options.rpId.length > 0) return options.rpId; if (options.rp && typeof options.rp.id === 'string' && options.rp.id.length > 0) return options.rp.id; return new URL(origin).hostname; } function clientDataJSON(type, challenge, origin) { return Buffer.from( JSON.stringify({ type, challenge: base64Url(challenge), origin, crossOrigin: false, }), 'utf8', ); } function clientDataHash(data) { return createHash('sha256').update(data).digest(); } function authenticatorAttachment(value) { if (value === 'platform') return WEBAUTHN_AUTHENTICATOR_ATTACHMENT_PLATFORM; if (value === 'cross-platform') return WEBAUTHN_AUTHENTICATOR_ATTACHMENT_CROSS_PLATFORM; return WEBAUTHN_AUTHENTICATOR_ATTACHMENT_ANY; } function userVerification(value) { if (value === 'required') return WEBAUTHN_USER_VERIFICATION_REQUIREMENT_REQUIRED; if (value === 'discouraged') return WEBAUTHN_USER_VERIFICATION_REQUIREMENT_DISCOURAGED; return WEBAUTHN_USER_VERIFICATION_REQUIREMENT_PREFERRED; } function attestation(value) { if (value === 'direct') return WEBAUTHN_ATTESTATION_CONVEYANCE_PREFERENCE_DIRECT; if (value === 'indirect') return WEBAUTHN_ATTESTATION_CONVEYANCE_PREFERENCE_INDIRECT; return WEBAUTHN_ATTESTATION_CONVEYANCE_PREFERENCE_NONE; } function enterpriseAttestation(value) { return value === 'enterprise' ? WEBAUTHN_ENTERPRISE_ATTESTATION_VENDOR_FACILITATED : WEBAUTHN_ENTERPRISE_ATTESTATION_NONE; } function transportBits(transports) { if (!Array.isArray(transports)) return 0; let bits = 0; for (const transport of transports) { if (transport === 'usb') bits |= WEBAUTHN_CTAP_TRANSPORT_USB; else if (transport === 'nfc') bits |= WEBAUTHN_CTAP_TRANSPORT_NFC; else if (transport === 'ble') bits |= WEBAUTHN_CTAP_TRANSPORT_BLE; else if (transport === 'internal') bits |= WEBAUTHN_CTAP_TRANSPORT_INTERNAL; else if (transport === 'hybrid') bits |= WEBAUTHN_CTAP_TRANSPORT_HYBRID; else if (transport === 'smart-card') bits |= WEBAUTHN_CTAP_TRANSPORT_SMART_CARD; } return bits; } function credentialDescriptors(descriptors) { if (!Array.isArray(descriptors)) return []; return descriptors.map((descriptor) => ({ id: Buffer.from(descriptor.id), transports: transportBits(descriptor.transports), })); } function windowHandleBuffer(value) { if (Buffer.isBuffer(value)) return value; if (value instanceof Uint8Array) return Buffer.from(value); return undefined; } function pinString(value) { return typeof value === 'string' && value.length > 0 ? value : undefined; } function residentKeyFlags(selection) { const residentKey = selection?.residentKey; const requireResidentKey = Boolean(selection?.requireResidentKey) || residentKey === 'required'; return { requireResidentKey, preferResidentKey: requireResidentKey || residentKey === 'preferred', }; } function normalizeCreateOptions(options) { if (!options || typeof options !== 'object') throw new TypeError('registration options must be an object'); const origin = normalizedOrigin(options.origin); const selection = options.authenticatorSelection || {}; const residentKeys = residentKeyFlags(selection); return { rpId: relyingPartyId(options, origin), rpName: options.rp && typeof options.rp.name === 'string' ? options.rp.name : relyingPartyId(options, origin), challenge: Buffer.from(options.challenge), userId: Buffer.from(options.user.id), userName: options.user.name, userDisplayName: options.user.displayName, clientDataJSON: clientDataJSON('webauthn.create', options.challenge, origin), pubKeyCredParams: options.pubKeyCredParams, excludeCredentials: credentialDescriptors(options.excludeCredentials), timeout: Number.isFinite(options.timeout) ? Math.max(0, Math.trunc(options.timeout)) : 0, authenticatorAttachment: authenticatorAttachment(selection.authenticatorAttachment), userVerification: userVerification(selection.userVerification), attestation: attestation(options.attestation), enterpriseAttestation: enterpriseAttestation(options.attestation), windowHandle: windowHandleBuffer(options.windowHandle), pin: pinString(options.pin), ...residentKeys, }; } function normalizeGetOptions(options) { if (!options || typeof options !== 'object') throw new TypeError('assertion options must be an object'); const origin = normalizedOrigin(options.origin); return { rpId: relyingPartyId(options, origin), challenge: Buffer.from(options.challenge), clientDataJSON: clientDataJSON('webauthn.get', options.challenge, origin), allowCredentials: credentialDescriptors(options.allowCredentials), timeout: Number.isFinite(options.timeout) ? Math.max(0, Math.trunc(options.timeout)) : 0, authenticatorAttachment: WEBAUTHN_AUTHENTICATOR_ATTACHMENT_ANY, userVerification: userVerification(options.userVerification), windowHandle: windowHandleBuffer(options.windowHandle), pin: pinString(options.pin), }; } function unavailableError() { return new Error( `@fluxer/webauthn native backend unavailable on ${process.platform}/${process.arch}: ${ loadError instanceof Error ? loadError.message : 'unknown load error' }`, ); } function requireBinding() { if (!binding) throw unavailableError(); return binding; } async function isSupported() { if (!binding || typeof binding.isSupported !== 'function') return false; return Boolean(await binding.isSupported()); } function rawIdCredential(rawId, response, authenticatorAttachment) { const id = base64Url(rawId); return { id, rawId, response: Buffer.from(JSON.stringify(response), 'utf8'), authenticatorAttachment, type: 'public-key', }; } async function create(options) { const native = requireBinding(); if (typeof native.create !== 'function') { throw new Error( `@fluxer/webauthn native backend did not export registration on ${process.platform}/${process.arch}`, ); } const normalized = normalizeCreateOptions(options); normalized.clientDataHash = clientDataHash(normalized.clientDataJSON); const result = await native.create(normalized); return rawIdCredential( result.rawId, { clientDataJSON: base64Url(result.clientDataJSON), attestationObject: base64Url(result.attestationObject), }, result.authenticatorAttachment, ); } async function get(options) { const native = requireBinding(); if (typeof native.get !== 'function') { throw new Error( `@fluxer/webauthn native backend did not export authentication on ${process.platform}/${process.arch}`, ); } const normalized = normalizeGetOptions(options); normalized.clientDataHash = clientDataHash(normalized.clientDataJSON); const result = await native.get(normalized); const response = { clientDataJSON: base64Url(result.clientDataJSON), authenticatorData: base64Url(result.authenticatorData), signature: base64Url(result.signature), }; if (result.userHandle) response.userHandle = base64Url(result.userHandle); return rawIdCredential(result.rawId, response, result.authenticatorAttachment); } function getBackendInfo() { if (!binding || typeof binding.getBackendInfo !== 'function') { return { target: `${process.platform}/${process.arch}`, backend: 'unavailable', nativeLoaded: false, ceremoniesImplemented: false, platformBrokerAvailable: false, platformAuthenticatorAvailable: false, supported: false, apiVersion: 0, reason: loadError instanceof Error ? loadError.message : 'native backend did not load', }; } return binding.getBackendInfo(); } module.exports = { create, get, getBackendInfo, isSupported, loadError, _private: { base64Url, clientDataJSON, nativeFileName, normalizeCreateOptions, normalizeGetOptions, resolveNativeRoot, transportBits, }, };