Files
WebDrop/web/src/core-bridge/passkeys.ts
T

59 lines
4.3 KiB
TypeScript

import { callWorker } from './workerClient';
import type { AccountStatus } from './account';
import type { PasskeyOptionsBody, PasskeyInfoBody } from './generated/api';
const apiBase = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1';
const call = <T>(operation: string, payload: object = {}) => callWorker<T>(operation, { ...payload, apiBase });
export const passkeysAvailable = () => typeof PublicKeyCredential !== 'undefined' && window.isSecureContext;
const decode = (value: string): Uint8Array<ArrayBuffer> => Uint8Array.from(atob(value.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(value.length / 4) * 4, '=')), c => c.charCodeAt(0));
interface PrfResult { enabled?: boolean; results?: { first?: ArrayBuffer } }
const prfResult = (credential: PublicKeyCredential) => (credential.getClientExtensionResults() as AuthenticationExtensionsClientOutputs & { prf?: PrfResult }).prf;
function credentialJson(credential: PublicKeyCredential): string {
// W3C PRF: omit client extension outputs when sending credentials to the server.
return JSON.stringify({ ...credential.toJSON(), clientExtensionResults: {} });
}
async function assertion(options: PasskeyOptionsBody, credentialId?: string): Promise<PublicKeyCredential> {
const json = JSON.parse(options.options) as PublicKeyCredentialRequestOptionsJSON;
const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(json);
if (credentialId) publicKey.allowCredentials = [{ type: 'public-key', id: decode(credentialId) }];
const evalByCredential = Object.fromEntries(options.credentials.filter(p => !credentialId || p.id === credentialId).map(p => [p.id, { first: decode(p.prf_salt) }]));
publicKey.extensions = { ...publicKey.extensions, prf: { evalByCredential } } as AuthenticationExtensionsClientInputs;
const credential = await navigator.credentials.get({ publicKey });
if (!(credential instanceof PublicKeyCredential)) throw new Error('Passkey验证未完成');
return credential;
}
export async function bindPasskey(password: string, name: string): Promise<void> {
if (!passkeysAvailable()) throw new Error('当前浏览器不支持Passkey,请使用HTTPS或localhost');
let prf: ArrayBuffer | undefined;
try {
const options = await call<PasskeyOptionsBody>('beginPasskeyRegistration', { password });
const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(JSON.parse(options.options));
publicKey.extensions = { ...publicKey.extensions, prf: { eval: { first: decode(options.prf_salt!) } } } as AuthenticationExtensionsClientInputs;
const credential = await navigator.credentials.create({ publicKey });
if (!(credential instanceof PublicKeyCredential)) throw new Error('Passkey绑定未完成');
const extension = prfResult(credential); prf = extension?.results?.first;
const registered = await call<PasskeyInfoBody>('finishPasskeyRegistration', { id: options.id, credentialId: credential.id, credential: credentialJson(credential), name, prf });
if (!registered.can_recover && extension?.enabled) {
const request = await call<PasskeyOptionsBody>('beginPasskeyRecovery');
const proof = await assertion(request, registered.id); prf = prfResult(proof)?.results?.first;
if (prf) await call<void>('finishPasskeyRecovery', { id: request.id, credentialId: proof.id, credential: credentialJson(proof), prf });
}
} finally {
if (prf) new Uint8Array(prf).fill(0);
await call('cancelPasskey').catch(() => {});
}
}
export async function loginWithPasskey(username: string, remember: boolean): Promise<AccountStatus> {
if (!passkeysAvailable()) throw new Error('当前浏览器不支持Passkey,请使用HTTPS或localhost');
const options = await call<PasskeyOptionsBody>('beginPasskeyLogin', { username });
const proof = await assertion(options); const prf = prfResult(proof)?.results?.first;
try { return await call<AccountStatus>('finishPasskeyLogin', { username, id: options.id, credential: credentialJson(proof), prf, remember }); }
finally { if (prf) new Uint8Array(prf).fill(0); }
}
export const passkeyApi = {
list: () => call<PasskeyInfoBody[]>('listPasskeys'),
remove: (id: string) => call<void>('removePasskey', { id }),
unlock: (password: string, remember: boolean) => call<AccountStatus>('unlockPasskeyLogin', { password, remember }),
cancel: () => call<AccountStatus>('cancelPasskey'),
};