diff --git a/package.json b/package.json index 8458b9c..9dcc2fd 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,9 @@ "domains:sync": "node scripts/sync-global-domains.mjs", "i18n": "node scripts/i18n-validate.cjs", "i18n:validate": "node scripts/i18n-validate.cjs", + "test:webauthn-mobile": "node --test scripts/webauthn-mobile-connector.test.mjs", + "test:webauthn-connector": "node --test scripts/webauthn-connector.test.mjs && tsx --test scripts/webauthn-connector-headers.test.ts", + "test:webauthn-connectors": "node --test scripts/webauthn-mobile-connector.test.mjs scripts/webauthn-connector.test.mjs && tsx --test scripts/webauthn-connector-headers.test.ts", "deploy": "wrangler deploy", "deploy:kv": "node scripts/ensure-kv.cjs && wrangler deploy -c wrangler.kv.toml", "deploy:demo": "npm run build:demo && wrangler pages deploy dist --project-name nw-demo" diff --git a/scripts/webauthn-connector-headers.test.ts b/scripts/webauthn-connector-headers.test.ts new file mode 100644 index 0000000..d367afb --- /dev/null +++ b/scripts/webauthn-connector-headers.test.ts @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +import type { Env } from '../src/types'; +import { getConfiguredWebAuthnAllowedOrigins } from '../src/utils/origins'; +import { applyCors, handleCors } from '../src/utils/response'; + +const env = {} as Env; + +test('only the iframe connector drops anti-framing headers', () => { + const connectorRequest = new Request('https://vault.example.test/webauthn-connector.html'); + const connector = applyCors(connectorRequest, new Response(''), env); + assert.equal(connector.headers.get('X-Frame-Options'), null); + assert.doesNotMatch(connector.headers.get('Content-Security-Policy') || '', /frame-ancestors/); + assert.match(connector.headers.get('Content-Security-Policy') || '', /script-src 'self'/); + + for (const path of ['/', '/webauthn-fallback-connector.html', '/webauthn-mobile-connector.html']) { + const request = new Request(`https://vault.example.test${path}`); + const response = applyCors(request, new Response(''), env); + assert.equal(response.headers.get('X-Frame-Options'), 'DENY'); + assert.match(response.headers.get('Content-Security-Policy') || '', /frame-ancestors 'none'/); + } +}); + +test('official Bitwarden desktop origin receives credentialed CORS', () => { + assert.ok(getConfiguredWebAuthnAllowedOrigins(env).includes('bw-desktop-file://bundle')); + const preflight = handleCors(new Request('https://vault.example.test/api/sync', { + method: 'OPTIONS', + headers: { + Origin: 'bw-desktop-file://bundle', + 'Access-Control-Request-Headers': 'authorization, content-type', + }, + }), env); + assert.equal(preflight.headers.get('Access-Control-Allow-Origin'), 'bw-desktop-file://bundle'); + assert.equal(preflight.headers.get('Access-Control-Allow-Credentials'), 'true'); +}); + +test('Worker assets preserve exact official connector .html paths', async () => { + for (const configUrl of [ + new URL('../wrangler.toml', import.meta.url), + new URL('../wrangler.kv.toml', import.meta.url), + ]) { + const config = await readFile(configUrl, 'utf8'); + const assetsSection = config.match(/\[assets\]([\s\S]*?)(?=\n\[|$)/)?.[1] || ''; + assert.match(assetsSection, /^\s*html_handling\s*=\s*"none"\s*$/m); + } +}); diff --git a/scripts/webauthn-connector.test.mjs b/scripts/webauthn-connector.test.mjs new file mode 100644 index 0000000..f9a2377 --- /dev/null +++ b/scripts/webauthn-connector.test.mjs @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +import { + buildCredentialData, + normalizePublicKeyOptions, + parseConnectorRequest, + resolveParentChannel, +} from '../webapp/public/webauthn-connector.js'; + +function encodeBase64Utf8(value) { + return Buffer.from(value, 'utf8').toString('base64'); +} + +const publicKeyOptions = { + challenge: 'AQID', + allowCredentials: [{ id: 'BAUG', type: 'public-key', transports: ['usb'] }], + timeout: 60000, + rpId: 'vault.example.test', +}; + +test('parses the official desktop/browser V1 connector request', () => { + const params = new URLSearchParams({ + data: encodeBase64Utf8(JSON.stringify(publicKeyOptions)), + parent: encodeURIComponent('file:///C:/Program Files/Bitwarden/resources/app/index.html'), + btnText: encodeURIComponent('Read security key'), + btnAwaitingInteractionText: encodeURIComponent('Awaiting security key interaction...'), + v: '1', + }); + const request = parseConnectorRequest(params); + assert.equal(request.parentUrl, 'file:///C:/Program Files/Bitwarden/resources/app/index.html'); + assert.equal(request.parentProtocol, 'file:'); + assert.deepEqual(JSON.parse(request.webauthnJson), publicKeyOptions); + assert.equal(request.buttonText, 'Read security key'); + assert.equal(request.awaitingText, 'Awaiting security key interaction...'); +}); + +test('keeps V2 parsing compatible with the shared official connector protocol', () => { + const params = new URLSearchParams({ + data: encodeBase64Utf8(JSON.stringify({ data: JSON.stringify(publicKeyOptions) })), + parent: encodeURIComponent('chrome-extension://nngceckbapebfimnlniiiahkandclblb/popup/index.html'), + v: '2', + }); + assert.deepEqual(JSON.parse(parseConnectorRequest(params).webauthnJson), publicKeyOptions); +}); + +test('normalizes WebAuthn challenge and allowed credential IDs', () => { + const normalized = normalizePublicKeyOptions(JSON.stringify(publicKeyOptions)); + assert.deepEqual(Array.from(normalized.challenge), [1, 2, 3]); + assert.deepEqual(Array.from(normalized.allowCredentials[0].id), [4, 5, 6]); +}); + +test('emits the exact assertion shape consumed by official Bitwarden clients', () => { + const output = JSON.parse(buildCredentialData({ + id: 'credential-id', + rawId: Uint8Array.from([1, 2, 3]).buffer, + type: 'public-key', + getClientExtensionResults: () => ({ appid: false }), + response: { + authenticatorData: Uint8Array.from([4, 5]).buffer, + clientDataJSON: Uint8Array.from([6, 7]).buffer, + signature: Uint8Array.from([8, 9]).buffer, + }, + })); + assert.deepEqual(output, { + id: 'credential-id', + rawId: 'AQID', + type: 'public-key', + extensions: { appid: false }, + response: { + authenticatorData: 'BAU', + clientDataJson: 'Bgc', + signature: 'CAk', + }, + }); +}); + +test('accepts legacy file and current official desktop parent origins', () => { + assert.deepEqual(resolveParentChannel({ + parentProtocol: 'file:', + parentUrl: 'file:///C:/Bitwarden/index.html', + }, 'https://vault.example.test'), { + eventOrigin: 'null', + targetOrigin: 'file:///C:/Bitwarden/index.html', + }); + assert.deepEqual(resolveParentChannel({ + parentProtocol: 'bw-desktop-file:', + parentUrl: 'bw-desktop-file://bundle/index.html', + }, 'https://vault.example.test'), { + eventOrigin: 'bw-desktop-file://bundle', + targetOrigin: 'bw-desktop-file://bundle/index.html', + }); +}); + +test('accepts configured official extension origins and rejects arbitrary parents', () => { + const extension = 'chrome-extension://nngceckbapebfimnlniiiahkandclblb'; + assert.deepEqual(resolveParentChannel({ + parentProtocol: 'chrome-extension:', + parentUrl: `${extension}/popup/index.html`, + }, 'https://vault.example.test', [extension]), { + eventOrigin: extension, + targetOrigin: extension, + }); + assert.throws(() => resolveParentChannel({ + parentProtocol: 'https:', + parentUrl: 'https://attacker.example/frame', + }, 'https://vault.example.test', []), /Untrusted parent/); +}); + +test('uses the official postMessage message contract and iframe-sized fallback styling', async () => { + const [html, source, viteConfig] = await Promise.all([ + readFile(new URL('../webapp/public/webauthn-connector.html', import.meta.url), 'utf8'), + readFile(new URL('../webapp/public/webauthn-connector.js', import.meta.url), 'utf8'), + readFile(new URL('../webapp/vite.config.ts', import.meta.url), 'utf8'), + ]); + assert.match(html, /id="webauthn-button"/); + assert.match(html, /min-height:\s*40px/); + assert.match(html, /background:\s*#2563eb/); + assert.match(source, /post\('info\|ready'\)/); + assert.match(source, /post\(`success\|\$\{buildCredentialData\(credential\)\}`\)/); + assert.match(source, /post\(`error\|\$\{browserErrorMessage\(error\)\}`\)/); + assert.match(source, /event\.data === 'stop'/); + assert.match(source, /event\.data === 'start'/); + assert.match(viteConfig, /endsWith\('-connector\.html'\)/); +}); diff --git a/scripts/webauthn-mobile-connector.test.mjs b/scripts/webauthn-mobile-connector.test.mjs new file mode 100644 index 0000000..4168312 --- /dev/null +++ b/scripts/webauthn-mobile-connector.test.mjs @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; +import { + base64UrlFromBuffer, + buildCallbackUrl, + buildCredentialData, + decodeBase64Utf8, + normalizePublicKeyOptions, + parseConnectorRequest, + resolveMobileCallbackUri, +} from '../webapp/public/webauthn-mobile-connector.js'; + +function encodeBase64Utf8(value) { + return Buffer.from(value, 'utf8').toString('base64'); +} + +function v2Search(payload, extra = '') { + return `?data=${encodeURIComponent(encodeBase64Utf8(JSON.stringify(payload)))}&parent=bitwarden%3A__webauthn-callback&v=2${extra}`; +} + +const assertionOptions = { + challenge: 'AQID-v8', + rpId: 'vault.example.com', + timeout: 60000, + userVerification: 'preferred', + allowCredentials: [{ id: 'BAUGBwg', type: 'public-key', transports: ['internal'] }], +}; + +test('parses the current Bitwarden Android V2 connector payload', () => { + const request = parseConnectorRequest(v2Search({ + btnReturnText: 'Return to app', btnText: 'Authenticate', data: JSON.stringify(assertionOptions), + headerText: 'Verify your identity', mobile: true, + }, '&client=mobile&deeplinkScheme=bitwarden'), 'vault.example.com'); + assert.equal(request.callbackUri, 'bitwarden://webauthn-callback'); + assert.equal(request.headerText, 'Verify your identity'); + assert.equal(request.buttonText, 'Authenticate'); + assert.equal(request.returnButtonText, 'Return to app'); + assert.deepEqual(JSON.parse(request.webauthnJson), assertionOptions); +}); + +test('uses callbackUri only as a signal and never as the redirect target', () => { + const trustedLooking = parseConnectorRequest(v2Search({ + callbackUri: 'https://bitwarden.eu/webauthn-callback', data: assertionOptions, + }).replace('&parent=bitwarden%3A__webauthn-callback', '')); + const attacker = parseConnectorRequest(v2Search({ + callbackUri: 'https://attacker.example/capture', data: assertionOptions, + }).replace('&parent=bitwarden%3A__webauthn-callback', '')); + assert.equal(trustedLooking.callbackUri, 'bitwarden://webauthn-callback'); + assert.equal(attacker.callbackUri, 'bitwarden://webauthn-callback'); +}); + +test('treats any non-HTTPS deeplinkScheme as the fixed Bitwarden custom scheme', () => { + const request = parseConnectorRequest(v2Search({ mobile: true, data: assertionOptions }, '&deeplinkScheme=untrusted')); + assert.equal(request.callbackUri, 'bitwarden://webauthn-callback'); +}); + +test('supports Android custom-scheme and official HTTPS App Link callbacks', () => { + const payload = { mobile: true, data: assertionOptions }; + const custom = parseConnectorRequest(v2Search(payload, '&client=mobile&deeplinkScheme=bitwarden')); + const eu = parseConnectorRequest(v2Search(payload, '&client=mobile&deeplinkScheme=https'), 'vault.bitwarden.eu'); + const selfHosted = parseConnectorRequest(v2Search(payload, '&client=mobile&deeplinkScheme=https'), 'vault.example.com'); + assert.equal(custom.callbackUri, 'bitwarden://webauthn-callback'); + assert.equal(eu.callbackUri, 'https://bitwarden.eu/webauthn-callback'); + assert.equal(selfHosted.callbackUri, 'https://bitwarden.com/webauthn-callback'); +}); + +test('supports V1 mobile requests and requires a recognized mobile signal', () => { + const encoded = encodeURIComponent(encodeBase64Utf8(JSON.stringify(assertionOptions))); + assert.equal(parseConnectorRequest(`?data=${encoded}&v=1&client=mobile`).callbackUri, 'bitwarden://webauthn-callback'); + assert.equal(resolveMobileCallbackUri({ payload: {}, hostname: 'vault.example.com' }), null); + assert.throws(() => parseConnectorRequest(`?data=${encoded}&v=1`), /return target/i); +}); + +test('decodes UTF-8 and normalizes WebAuthn binary fields without mutation', () => { + assert.equal(decodeBase64Utf8(encodeBase64Utf8('验证身份')), '验证身份'); + const original = structuredClone(assertionOptions); + const normalized = normalizePublicKeyOptions(original); + assert.deepEqual(Array.from(normalized.challenge), [1, 2, 3, 250, 255]); + assert.deepEqual(Array.from(normalized.allowCredentials[0].id), [4, 5, 6, 7, 8]); + assert.deepEqual(original, assertionOptions); +}); + +test('serializes the exact assertion shape emitted by Bitwarden common-webauthn', () => { + const serialized = JSON.parse(buildCredentialData({ + id: 'credential-id', rawId: Uint8Array.from([1, 2, 255]).buffer, type: 'public-key', + getClientExtensionResults: () => ({ appid: false }), + response: { + authenticatorData: Uint8Array.from([3, 4]).buffer, + clientDataJSON: Uint8Array.from([5, 6]).buffer, + signature: Uint8Array.from([7, 8]).buffer, + userHandle: Uint8Array.from([9, 10]).buffer, + }, + })); + assert.deepEqual(serialized, { + id: 'credential-id', + rawId: 'AQL_', + type: 'public-key', + extensions: { appid: false }, + response: { authenticatorData: 'AwQ', clientDataJson: 'BQY', signature: 'Bwg' }, + }); + assert.equal(base64UrlFromBuffer(Uint8Array.from([251, 255])), '-_8'); +}); + +test('encodes success and error callbacks safely', () => { + assert.equal(buildCallbackUrl('bitwarden://webauthn-callback', 'data', '{"id":"a+b"}'), 'bitwarden://webauthn-callback?data=%7B%22id%22%3A%22a%2Bb%22%7D'); + assert.equal(buildCallbackUrl('bitwarden://webauthn-callback?source=nodewarden', 'error', 'Not allowed'), 'bitwarden://webauthn-callback?source=nodewarden&error=Not%20allowed'); +}); + +test('HTML matches the fallback connector visual structure', async () => { + const html = await readFile(new URL('../webapp/public/webauthn-mobile-connector.html', import.meta.url), 'utf8'); + assert.match(html, /id="webauthn-header"/); + assert.match(html, /id="webauthn-button"/); + assert.match(html, /class="connector-card"/); + assert.match(html, /class="brand"/); + assert.match(html, /class="form"/); + assert.match(html, /class="msg"/); + assert.match(html, /src="\/nodewarden-logo\.svg"/); + assert.match(html, /src="\/webauthn-mobile-connector\.js"/); + assert.match(html, /default-src 'none'/); +}); + +test('runtime uses Bitwarden-compatible replacement navigation', async () => { + const source = await readFile(new URL('../webapp/public/webauthn-mobile-connector.js', import.meta.url), 'utf8'); + assert.match(source, /window\.location\.replace\(uri\)/); + assert.doesNotMatch(source, /location\.assign/); + assert.doesNotMatch(source, /safeCallbackFromPayload/); +}); + +test('Service Worker keeps connector navigations out of the SPA shell', async () => { + const config = await readFile(new URL('../webapp/vite.config.ts', import.meta.url), 'utf8'); + assert.match(config, /url\.pathname\.endsWith\('-connector\.html'\)/); + assert.match(config, /connectorNavigation\(request\)/); + assert.match(config, /WebAuthn connector is unavailable while offline/); +}); diff --git a/src/utils/origins.ts b/src/utils/origins.ts index d6a12fd..944aa93 100644 --- a/src/utils/origins.ts +++ b/src/utils/origins.ts @@ -8,6 +8,13 @@ export const OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS = [ 'chrome-extension://ccnckbpmaceehanjmeomladnmlffdjgn', ] as const; +// Bitwarden desktop is migrating from file:// to this privileged Electron +// origin. Official clients keep the legacy file:// path as a compatibility +// fallback while self-hosted servers add CORS support for the new origin. +export const OFFICIAL_BITWARDEN_DESKTOP_ORIGINS = [ + 'bw-desktop-file://bundle', +] as const; + export function normalizeOrigin(value: unknown): string | null { const raw = String(value || '').trim(); if (!raw) return null; @@ -30,10 +37,20 @@ export function isBrowserExtensionOrigin(origin: unknown): boolean { ); } +export function isOfficialBitwardenDesktopOrigin(origin: unknown): boolean { + const normalized = normalizeOrigin(origin); + return !!normalized && OFFICIAL_BITWARDEN_DESKTOP_ORIGINS.includes( + normalized as (typeof OFFICIAL_BITWARDEN_DESKTOP_ORIGINS)[number] + ); +} + export function getConfiguredWebAuthnAllowedOrigins( env: Pick ): string[] { - const seen = new Set(OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS); + const seen = new Set([ + ...OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS, + ...OFFICIAL_BITWARDEN_DESKTOP_ORIGINS, + ]); for (const item of String(env.WEBAUTHN_ALLOWED_ORIGINS || '').split(',')) { const origin = normalizeOrigin(item); if (origin) seen.add(origin); diff --git a/src/utils/response.ts b/src/utils/response.ts index 2f3a008..030ef18 100644 --- a/src/utils/response.ts +++ b/src/utils/response.ts @@ -3,6 +3,7 @@ import type { Env } from '../types'; import { isBrowserExtensionOrigin, isConfiguredWebAuthnAllowedOrigin, + isOfficialBitwardenDesktopOrigin, normalizeOrigin, } from './origins'; @@ -48,7 +49,10 @@ function getCorsPolicy(request: Request, env: Env): { allowOrigin: string | null if (origin === url.origin) { return { allowOrigin: origin, allowCredentials: true }; } - if (isBrowserExtensionOrigin(origin) && isConfiguredWebAuthnAllowedOrigin(env, origin)) { + if ( + (isBrowserExtensionOrigin(origin) || isOfficialBitwardenDesktopOrigin(origin)) + && isConfiguredWebAuthnAllowedOrigin(env, origin) + ) { return { allowOrigin: origin, allowCredentials: true }; } if (isWildcardCorsPath(url.pathname)) { @@ -100,10 +104,22 @@ export function applyCors( headers.set(k, v); } // Security headers applied to every response. - headers.set('X-Frame-Options', 'DENY'); headers.set('X-Content-Type-Options', 'nosniff'); headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); - if (!headers.has('Content-Security-Policy')) { + const isWebAuthnFrameConnector = new URL(request.url).pathname === '/webauthn-connector.html'; + if (isWebAuthnFrameConnector) { + // Official desktop and browser clients render this exact endpoint inside a + // 40px cross-origin iframe. The connector validates its parent before any + // WebAuthn request or postMessage, so only this protocol page may be framed. + headers.delete('X-Frame-Options'); + headers.set( + 'Content-Security-Policy', + "default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'" + ); + } else { + headers.set('X-Frame-Options', 'DENY'); + } + if (!isWebAuthnFrameConnector && !headers.has('Content-Security-Policy')) { headers.set('Content-Security-Policy', "frame-ancestors 'none'; img-src 'self' data:"); } return new Response(response.body, { diff --git a/webapp/public/webauthn-connector.html b/webapp/public/webauthn-connector.html new file mode 100644 index 0000000..af0df95 --- /dev/null +++ b/webapp/public/webauthn-connector.html @@ -0,0 +1,74 @@ + + + + + + + + NodeWarden WebAuthn Connector + + + + + + + diff --git a/webapp/public/webauthn-connector.js b/webapp/public/webauthn-connector.js new file mode 100644 index 0000000..55b57bd --- /dev/null +++ b/webapp/public/webauthn-connector.js @@ -0,0 +1,268 @@ +const OFFICIAL_DESKTOP_ORIGIN = 'bw-desktop-file://bundle'; + +function safeDecodeURIComponent(value) { + let decoded = String(value || ''); + for (let index = 0; index < 2 && /%[0-9a-f]{2}/i.test(decoded); index += 1) { + try { + const next = decodeURIComponent(decoded); + if (next === decoded) break; + decoded = next; + } catch (_error) { + break; + } + } + return decoded; +} + +export function decodeBase64Utf8(value) { + let normalized = String(value || '').replace(/ /g, '+').replace(/-/g, '+').replace(/_/g, '/'); + normalized += '='.repeat((4 - (normalized.length % 4 || 4)) % 4); + let binary; + try { + binary = atob(normalized); + } catch (_error) { + throw new Error('Cannot parse WebAuthn data.'); + } + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)); + if (typeof TextDecoder !== 'undefined') return new TextDecoder().decode(bytes); + return decodeURIComponent(Array.from(bytes, (byte) => `%${byte.toString(16).padStart(2, '0')}`).join('')); +} + +export function bytesFromBase64Url(value) { + let normalized = String(value || '').replace(/-/g, '+').replace(/_/g, '/'); + normalized += '='.repeat((4 - (normalized.length % 4 || 4)) % 4); + try { + return Uint8Array.from(atob(normalized), (character) => character.charCodeAt(0)); + } catch (_error) { + throw new Error('Cannot parse WebAuthn data.'); + } +} + +export function base64UrlFromBuffer(value) { + const bytes = value instanceof Uint8Array ? value : new Uint8Array(value); + let binary = ''; + for (let index = 0; index < bytes.length; index += 1) binary += String.fromCharCode(bytes[index]); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +} + +export function parseConnectorRequest(search) { + const params = search instanceof URLSearchParams + ? search + : new URLSearchParams(String(search || '').replace(/^\?/, '')); + const parentUrl = safeDecodeURIComponent(params.get('parent')); + const encodedData = params.get('data'); + if (!parentUrl) throw new Error('No parent.'); + if (!encodedData) throw new Error('No data.'); + + let parsedParent; + try { + parsedParent = new URL(parentUrl); + } catch (_error) { + throw new Error('Invalid parent.'); + } + + let webauthnJson; + if (params.get('v') === '1') { + webauthnJson = decodeBase64Utf8(encodedData); + } else { + let payload; + try { + payload = JSON.parse(decodeBase64Utf8(encodedData)); + } catch (_error) { + throw new Error('Cannot parse data.'); + } + if (!payload || (typeof payload.data !== 'string' && typeof payload.data !== 'object')) { + throw new Error('Cannot parse data.'); + } + webauthnJson = typeof payload.data === 'string' ? payload.data : JSON.stringify(payload.data); + } + + return { + parentUrl, + parentProtocol: parsedParent.protocol.toLowerCase(), + parentOrigin: parsedParent.origin, + webauthnJson, + buttonText: safeDecodeURIComponent(params.get('btnText')), + awaitingText: safeDecodeURIComponent(params.get('btnAwaitingInteractionText')), + }; +} + +export function normalizePublicKeyOptions(webauthnJson) { + const source = typeof webauthnJson === 'string' ? JSON.parse(webauthnJson) : webauthnJson; + if (!source || typeof source !== 'object' || !source.challenge) throw new Error('Cannot parse WebAuthn data.'); + const publicKey = { ...source, challenge: bytesFromBase64Url(source.challenge) }; + if (Array.isArray(source.allowCredentials)) { + publicKey.allowCredentials = source.allowCredentials.map((credential) => ({ + ...credential, + id: bytesFromBase64Url(credential?.id), + })); + } + return publicKey; +} + +export function buildCredentialData(assertedCredential) { + const response = assertedCredential?.response; + if (!assertedCredential || !response?.authenticatorData || !response?.clientDataJSON || !response?.signature) { + throw new Error('The authenticator returned an incomplete response.'); + } + return JSON.stringify({ + id: assertedCredential.id, + rawId: base64UrlFromBuffer(assertedCredential.rawId), + type: assertedCredential.type, + extensions: typeof assertedCredential.getClientExtensionResults === 'function' + ? assertedCredential.getClientExtensionResults() + : {}, + response: { + authenticatorData: base64UrlFromBuffer(response.authenticatorData), + clientDataJson: base64UrlFromBuffer(response.clientDataJSON), + signature: base64UrlFromBuffer(response.signature), + }, + }); +} + +function normalizeAllowedOrigin(value) { + try { + const url = new URL(String(value || '')); + return url.protocol && url.host ? `${url.protocol}//${url.host}` : ''; + } catch (_error) { + return ''; + } +} + +function isExtensionOrigin(origin) { + return origin.startsWith('chrome-extension://') + || origin.startsWith('moz-extension://') + || origin.startsWith('safari-web-extension://'); +} + +export function resolveParentChannel(request, connectorOrigin, allowedOrigins = []) { + if (request.parentProtocol === 'file:') { + return { eventOrigin: 'null', targetOrigin: request.parentUrl }; + } + + const parentOrigin = normalizeAllowedOrigin(request.parentUrl); + if (!parentOrigin) throw new Error('Invalid parent.'); + if (parentOrigin === connectorOrigin) { + return { eventOrigin: parentOrigin, targetOrigin: parentOrigin }; + } + if (parentOrigin === OFFICIAL_DESKTOP_ORIGIN) { + return { eventOrigin: parentOrigin, targetOrigin: request.parentUrl }; + } + const trustedOrigins = allowedOrigins.map(normalizeAllowedOrigin).filter(Boolean); + if (isExtensionOrigin(parentOrigin) && trustedOrigins.includes(parentOrigin)) { + return { eventOrigin: parentOrigin, targetOrigin: parentOrigin }; + } + throw new Error('Untrusted parent.'); +} + +async function loadAllowedParentOrigins() { + try { + const response = await fetch('/api/web-bootstrap', { + headers: { Accept: 'application/json' }, + credentials: 'omit', + }); + if (!response.ok) return []; + const body = await response.json(); + return Array.isArray(body?.webAuthnAllowedOrigins) ? body.webAuthnAllowedOrigins : []; + } catch (_error) { + return []; + } +} + +function browserErrorMessage(error) { + return error?.message || String(error || 'WebAuthn failed.'); +} + +async function initializePage() { + const button = document.getElementById('webauthn-button'); + if (!button) return; + + let request; + let publicKey; + let channel; + let stopWebAuthn = false; + let sentSuccess = false; + let running = false; + + const defaultText = 'Read security key'; + const awaitingDefaultText = 'Awaiting security key interaction...'; + + function setButton(awaiting = false) { + button.textContent = awaiting + ? request?.awaitingText || awaitingDefaultText + : request?.buttonText || defaultText; + button.setAttribute('aria-disabled', awaiting ? 'true' : 'false'); + button.setAttribute('aria-busy', awaiting ? 'true' : 'false'); + button.onclick = awaiting ? null : executeWebAuthn; + } + + function post(message) { + window.parent.postMessage(message, channel.targetOrigin); + } + + function reportError(error) { + if (channel) post(`error|${browserErrorMessage(error)}`); + setButton(false); + } + + async function executeWebAuthn() { + if (running || sentSuccess) return; + if (stopWebAuthn) { + stopWebAuthn = false; + setButton(false); + return; + } + running = true; + setButton(true); + try { + const credential = await navigator.credentials.get({ publicKey }); + if (!credential) throw new Error('No security key was selected.'); + if (sentSuccess) return; + post(`success|${buildCredentialData(credential)}`); + sentSuccess = true; + } catch (error) { + reportError(error); + } finally { + running = false; + } + } + + try { + request = parseConnectorRequest(window.location.search); + publicKey = normalizePublicKeyOptions(request.webauthnJson); + channel = resolveParentChannel(request, window.location.origin, await loadAllowedParentOrigins()); + setButton(false); + } catch (error) { + button.textContent = browserErrorMessage(error); + button.setAttribute('aria-disabled', 'true'); + return; + } + + if (!navigator.credentials || typeof navigator.credentials.get !== 'function' || !window.PublicKeyCredential) { + reportError(new Error('WebAuthn is not supported in this browser.')); + return; + } + + window.addEventListener('message', (event) => { + if (event.source !== window.parent || event.origin !== channel.eventOrigin) return; + if (event.data === 'stop') { + stopWebAuthn = true; + setButton(false); + } else if (event.data === 'start' && stopWebAuthn) { + stopWebAuthn = false; + void executeWebAuthn(); + } + }); + + post('info|ready'); + const isSafari = navigator.userAgent.includes(' Safari/') && !navigator.userAgent.includes('Chrome'); + if (!isSafari) void executeWebAuthn(); +} + +if (typeof window !== 'undefined' && typeof document !== 'undefined') { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => void initializePage(), { once: true }); + } else { + void initializePage(); + } +} diff --git a/webapp/public/webauthn-mobile-connector.html b/webapp/public/webauthn-mobile-connector.html new file mode 100644 index 0000000..6d07a2d --- /dev/null +++ b/webapp/public/webauthn-mobile-connector.html @@ -0,0 +1,174 @@ + + + + + + + + + + NodeWarden WebAuthn Connector + + + +
+
+
+ NodeWarden + NodeWarden +
+

Verify your identity

+

Use your security key to finish two-step verification.

+
+ + +
+
+
+ + + diff --git a/webapp/public/webauthn-mobile-connector.js b/webapp/public/webauthn-mobile-connector.js new file mode 100644 index 0000000..61361af --- /dev/null +++ b/webapp/public/webauthn-mobile-connector.js @@ -0,0 +1,272 @@ +const CUSTOM_SCHEME_CALLBACK = 'bitwarden://webauthn-callback'; +const APP_LINK_HOSTS = ['bitwarden.com', 'bitwarden.eu', 'bitwarden.pw', 'bitwarden-gov.com']; + +function safeDecodeURIComponent(value) { + let decoded = String(value || ''); + for (let index = 0; index < 2 && /%[0-9a-f]{2}/i.test(decoded); index += 1) { + try { + decoded = decodeURIComponent(decoded); + } catch (_error) { + break; + } + } + return decoded; +} + +export function decodeBase64Utf8(value) { + let normalized = String(value || '').replace(/ /g, '+').replace(/-/g, '+').replace(/_/g, '/'); + normalized += '='.repeat((4 - (normalized.length % 4 || 4)) % 4); + let binary; + try { + binary = atob(normalized); + } catch (_error) { + throw new Error('The WebAuthn challenge is not valid Base64.'); + } + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)); + if (typeof TextDecoder !== 'undefined') return new TextDecoder().decode(bytes); + return decodeURIComponent(Array.from(bytes, (byte) => `%${byte.toString(16).padStart(2, '0')}`).join('')); +} + +export function bytesFromBase64Url(value) { + let normalized = String(value || '').replace(/-/g, '+').replace(/_/g, '/'); + normalized += '='.repeat((4 - (normalized.length % 4 || 4)) % 4); + try { + return Uint8Array.from(atob(normalized), (character) => character.charCodeAt(0)); + } catch (_error) { + throw new Error('The WebAuthn challenge contains invalid binary data.'); + } +} + +export function base64UrlFromBuffer(value) { + if (value == null) return undefined; + const bytes = value instanceof Uint8Array ? value : new Uint8Array(value); + let binary = ''; + for (let index = 0; index < bytes.length; index += 1) binary += String.fromCharCode(bytes[index]); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +} + +function officialAppLinkHost(hostname) { + const normalized = String(hostname || '').toLowerCase(); + return APP_LINK_HOSTS.find((host) => normalized === host || normalized.endsWith(`.${host}`)) || 'bitwarden.com'; +} + +export function resolveMobileCallbackUri({ deeplinkScheme, payload, hostname, legacyMobile = false }) { + // Match Bitwarden's connector protocol: the scheme parameter governs the + // callback shape. Any non-HTTPS scheme resolves to Bitwarden's fixed custom + // scheme; a client-provided callbackUri is only a mobile-flow signal. + if (deeplinkScheme) { + return String(deeplinkScheme).toLowerCase() === 'https' + ? `https://${officialAppLinkHost(hostname)}/webauthn-callback` + : CUSTOM_SCHEME_CALLBACK; + } + return payload?.mobile === true || payload?.callbackUri != null || legacyMobile + ? CUSTOM_SCHEME_CALLBACK + : null; +} + +export function parseConnectorRequest(search, hostname = '') { + const params = search instanceof URLSearchParams + ? search + : new URLSearchParams(String(search || '').replace(/^\?/, '')); + const encodedData = params.get('data'); + if (!encodedData) throw new Error('No WebAuthn challenge was provided.'); + + const version = params.get('v'); + let payload = null; + let webauthnJson; + let headerText; + let buttonText; + let returnButtonText; + let awaitingText; + + if (version === '1') { + webauthnJson = decodeBase64Utf8(encodedData); + headerText = params.get('headerText'); + buttonText = params.get('btnText'); + returnButtonText = params.get('btnReturnText'); + awaitingText = params.get('btnAwaitingInteractionText'); + } else { + try { + payload = JSON.parse(decodeBase64Utf8(encodedData)); + } catch (_error) { + throw new Error('The WebAuthn challenge could not be decoded.'); + } + if (!payload || (typeof payload.data !== 'string' && typeof payload.data !== 'object')) { + throw new Error('The WebAuthn challenge is incomplete.'); + } + webauthnJson = typeof payload.data === 'string' ? payload.data : JSON.stringify(payload.data); + headerText = payload.headerText; + buttonText = payload.btnText; + returnButtonText = payload.btnReturnText; + awaitingText = payload.btnAwaitingInteractionText; + } + + const callbackUri = resolveMobileCallbackUri({ + deeplinkScheme: params.get('deeplinkScheme'), + payload, + hostname, + legacyMobile: params.get('client') === 'mobile', + }); + if (!callbackUri) throw new Error('No supported mobile return target was provided.'); + + return { + callbackUri, + webauthnJson, + headerText: safeDecodeURIComponent(headerText), + buttonText: safeDecodeURIComponent(buttonText), + returnButtonText: safeDecodeURIComponent(returnButtonText), + awaitingText: safeDecodeURIComponent(awaitingText), + }; +} + +export function normalizePublicKeyOptions(webauthnJson) { + const source = typeof webauthnJson === 'string' ? JSON.parse(webauthnJson) : webauthnJson; + if (!source || typeof source !== 'object' || !source.challenge) { + throw new Error('The WebAuthn challenge is invalid.'); + } + const publicKey = { ...source, challenge: bytesFromBase64Url(source.challenge) }; + if (Array.isArray(source.allowCredentials)) { + publicKey.allowCredentials = source.allowCredentials.map((credential) => ({ + ...credential, + id: bytesFromBase64Url(credential?.id), + })); + } + return publicKey; +} + +export function buildCredentialData(assertedCredential) { + const response = assertedCredential?.response; + if (!assertedCredential || !response?.authenticatorData || !response?.clientDataJSON || !response?.signature) { + throw new Error('The authenticator returned an incomplete response.'); + } + const extensions = typeof assertedCredential.getClientExtensionResults === 'function' + ? assertedCredential.getClientExtensionResults() + : {}; + const clientData = base64UrlFromBuffer(response.clientDataJSON); + return JSON.stringify({ + id: assertedCredential.id, + rawId: base64UrlFromBuffer(assertedCredential.rawId), + type: assertedCredential.type, + extensions, + response: { + authenticatorData: base64UrlFromBuffer(response.authenticatorData), + clientDataJson: clientData, + signature: base64UrlFromBuffer(response.signature), + }, + }); +} + +export function buildCallbackUrl(callbackUri, key, value) { + const separator = String(callbackUri).includes('?') ? '&' : '?'; + return `${callbackUri}${separator}${encodeURIComponent(key)}=${encodeURIComponent(String(value || ''))}`; +} + +function translations(locale) { + const normalized = String(locale || 'en').toLowerCase(); + if (normalized.startsWith('zh-tw') || normalized.startsWith('zh-hk')) { + return { + title: '兩步驟驗證', copy: '使用通行密鑰或安全金鑰完成登入。', button: '使用通行密鑰驗證', + awaiting: '請依照系統提示完成驗證…', returning: '正在返回 Bitwarden…', returnButton: '返回 Bitwarden', + unsupported: '此瀏覽器不支援通行密鑰。', cancelled: '驗證已取消,請重試。', + }; + } + if (normalized.startsWith('zh')) { + return { + title: '两步验证', copy: '使用通行密钥或安全密钥完成登录。', button: '使用通行密钥验证', + awaiting: '请按照系统提示完成验证…', returning: '正在返回 Bitwarden…', returnButton: '返回 Bitwarden', + unsupported: '此浏览器不支持通行密钥。', cancelled: '验证已取消,请重试。', + }; + } + return { + title: 'Two-step verification', copy: 'Use your passkey or security key to finish signing in.', + button: 'Authenticate with passkey', awaiting: 'Follow the system prompt to continue…', + returning: 'Returning to Bitwarden…', returnButton: 'Return to Bitwarden', + unsupported: 'This browser does not support passkeys.', cancelled: 'Verification was cancelled. Please try again.', + }; +} + +function browserErrorMessage(error, text) { + if (error?.name === 'NotAllowedError' || error?.name === 'AbortError') return text.cancelled; + return error?.message || String(error || 'WebAuthn failed.'); +} + +function initializePage() { + const button = document.getElementById('webauthn-button'); + const header = document.getElementById('webauthn-header'); + const copy = document.getElementById('webauthn-copy'); + const status = document.getElementById('webauthn-status'); + if (!button || !header || !copy || !status) return; + + const text = translations(navigator.languages?.[0] || navigator.language); + document.documentElement.lang = navigator.languages?.[0] || navigator.language || 'en'; + copy.textContent = text.copy; + let request; + let publicKey; + let completed = false; + let returnUri = ''; + + function setButton(label, state, handler) { + button.textContent = label; + button.dataset.state = state; + button.disabled = state === 'unavailable'; + button.setAttribute('aria-disabled', handler ? 'false' : 'true'); + button.setAttribute('aria-busy', state === 'waiting' ? 'true' : 'false'); + button.onclick = handler; + } + + function setStatus(kind, message) { + status.hidden = !message; + status.dataset.kind = kind; + status.textContent = message || ''; + status.className = message ? `msg show ${kind}` : 'msg'; + } + + function navigate(uri) { + returnUri = uri; + window.location.replace(uri); + setButton(request?.returnButtonText || text.returnButton, 'return', () => window.location.replace(returnUri)); + } + + function handoffError(message) { + setStatus('error', message); + if (request?.callbackUri) navigate(buildCallbackUrl(request.callbackUri, 'error', message)); + } + + async function executeWebAuthn() { + if (completed || button.dataset.state === 'waiting') return; + setStatus('info', request.awaitingText || text.awaiting); + setButton(request.awaitingText || text.awaiting, 'waiting', null); + try { + const credential = await navigator.credentials.get({ publicKey }); + if (!credential) throw new Error('No passkey was selected.'); + const data = buildCredentialData(credential); + completed = true; + setStatus('success', text.returning); + navigate(buildCallbackUrl(request.callbackUri, 'data', data)); + } catch (error) { + setButton(request.buttonText || text.button, 'ready', executeWebAuthn); + handoffError(browserErrorMessage(error, text)); + } + } + + try { + request = parseConnectorRequest(window.location.search, window.location.hostname); + publicKey = normalizePublicKeyOptions(request.webauthnJson); + header.textContent = request.headerText || text.title; + setButton(request.buttonText || text.button, 'ready', executeWebAuthn); + } catch (error) { + header.textContent = text.title; + setStatus('error', browserErrorMessage(error, text)); + setButton(text.button, 'unavailable', null); + } + + if (!navigator.credentials || typeof navigator.credentials.get !== 'function' || !window.PublicKeyCredential) { + handoffError(text.unsupported); + if (!request?.callbackUri) setButton(text.button, 'unavailable', null); + } +} + +if (typeof window !== 'undefined' && typeof document !== 'undefined') { + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initializePage, { once: true }); + else initializePage(); +} diff --git a/webapp/vite.config.ts b/webapp/vite.config.ts index 3c85cd2..06c057e 100644 --- a/webapp/vite.config.ts +++ b/webapp/vite.config.ts @@ -102,6 +102,28 @@ async function appShellNavigation(request) { ); } +async function connectorNavigation(request) { + const runtimeCache = await caches.open(RUNTIME_CACHE); + try { + const response = await fetch(request); + if (isCacheableResponse(response)) { + await runtimeCache.put(request, response.clone()); + await trimRuntimeCache(runtimeCache, 120); + } + return response; + } catch { + const shellCache = await caches.open(APP_SHELL_CACHE); + const cached = + (await shellCache.match(request, { ignoreSearch: true })) + || (await runtimeCache.match(request, { ignoreSearch: true })) + || (await matchLegacyRuntimeCache(request)); + return cached || new Response('WebAuthn connector is unavailable while offline.', { + status: 503, + headers: { 'Content-Type': 'text/plain; charset=UTF-8' }, + }); + } +} + async function trimRuntimeCache(cache, maxEntries) { const keys = await cache.keys(); if (keys.length <= maxEntries) return; @@ -145,6 +167,13 @@ self.addEventListener('fetch', (event) => { const url = new URL(request.url); if (NEVER_CACHE_PATH_RE.test(url.pathname)) return; + // Connector navigations are protocol pages, not application routes. They must + // never be replaced with the SPA shell, even when the device is offline. + if (url.pathname.endsWith('-connector.html')) { + event.respondWith(connectorNavigation(request)); + return; + } + if (request.mode === 'navigate') { event.respondWith(appShellNavigation(request)); if (navigator.onLine !== false) { diff --git a/wrangler.kv.toml b/wrangler.kv.toml index c5e3453..2bfde78 100644 --- a/wrangler.kv.toml +++ b/wrangler.kv.toml @@ -8,6 +8,7 @@ command = "npm run build" [assets] binding = "ASSETS" directory = "./dist" +html_handling = "none" not_found_handling = "single-page-application" run_worker_first = true diff --git a/wrangler.toml b/wrangler.toml index 75db16b..e535155 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -8,6 +8,7 @@ command = "npm run build" [assets] binding = "ASSETS" directory = "./dist" +html_handling = "none" not_found_handling = "single-page-application" run_worker_first = true