mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-21 13:20:13 +00:00
feat: implement account passkey functionality
- Added functions for managing account passkeys including creation, listing, updating, and deletion. - Introduced login methods using account passkeys with options for direct unlock and login-only modes. - Enhanced error handling and response parsing for passkey-related API calls. - Updated UI styles for account passkey management components. - Added new translations for account passkey features in multiple languages. - Modified network status handling to improve service reachability checks.
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
import { base64ToBytes, bytesToBase64, decryptBw, encryptBw, hkdfExpand, toBufferSource } from './crypto';
|
||||
import type { AccountPasskeyPrfOption } from './types';
|
||||
|
||||
const LOGIN_WITH_PRF_SALT = 'passwordless-login';
|
||||
|
||||
export interface AccountPasskeyAssertion {
|
||||
token: string;
|
||||
deviceResponse: Record<string, unknown>;
|
||||
prfKey?: Uint8Array;
|
||||
}
|
||||
|
||||
export interface PendingAccountPasskeyCredential {
|
||||
token: string;
|
||||
createOptions: PublicKeyCredentialCreationOptions;
|
||||
deviceResponse: PublicKeyCredential;
|
||||
request: Record<string, unknown>;
|
||||
supportsPrf: boolean;
|
||||
}
|
||||
|
||||
export interface AccountPasskeyPrfKeySet {
|
||||
encryptedUserKey: string;
|
||||
encryptedPublicKey: string;
|
||||
encryptedPrivateKey: string;
|
||||
}
|
||||
|
||||
function bytesToBase64Url(bytes: Uint8Array): string {
|
||||
return bytesToBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
function base64UrlToBytes(value: string): Uint8Array {
|
||||
const normalized = String(value || '').replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = normalized + '='.repeat((4 - (normalized.length % 4 || 4)) % 4);
|
||||
return base64ToBytes(padded);
|
||||
}
|
||||
|
||||
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
return toBufferSource(bytes);
|
||||
}
|
||||
|
||||
function cloneCreationOptions(options: any): PublicKeyCredentialCreationOptions {
|
||||
if (!options || typeof options !== 'object') throw new Error('Invalid passkey creation options');
|
||||
return {
|
||||
...options,
|
||||
challenge: toArrayBuffer(base64UrlToBytes(options.challenge)),
|
||||
user: {
|
||||
...options.user,
|
||||
id: toArrayBuffer(base64UrlToBytes(options.user?.id)),
|
||||
},
|
||||
excludeCredentials: Array.isArray(options.excludeCredentials)
|
||||
? options.excludeCredentials.map((credential: any) => ({
|
||||
...credential,
|
||||
id: toArrayBuffer(base64UrlToBytes(credential.id)),
|
||||
}))
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneRequestOptions(options: any): PublicKeyCredentialRequestOptions {
|
||||
if (!options || typeof options !== 'object') throw new Error('Invalid passkey assertion options');
|
||||
return {
|
||||
...options,
|
||||
challenge: toArrayBuffer(base64UrlToBytes(options.challenge)),
|
||||
allowCredentials: Array.isArray(options.allowCredentials)
|
||||
? options.allowCredentials.map((credential: any) => ({
|
||||
...credential,
|
||||
id: toArrayBuffer(base64UrlToBytes(credential.id)),
|
||||
}))
|
||||
: options.allowCredentials,
|
||||
};
|
||||
}
|
||||
|
||||
async function getLoginWithPrfSalt(): Promise<Uint8Array> {
|
||||
const hash = await crypto.subtle.digest('SHA-256', toBufferSource(new TextEncoder().encode(LOGIN_WITH_PRF_SALT)));
|
||||
return new Uint8Array(hash);
|
||||
}
|
||||
|
||||
function credentialIdToBase64Url(id: BufferSource): string | null {
|
||||
try {
|
||||
const bytes = id instanceof ArrayBuffer
|
||||
? new Uint8Array(id)
|
||||
: new Uint8Array(id.buffer, id.byteOffset, id.byteLength);
|
||||
return bytesToBase64Url(bytes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildPrfExtension(
|
||||
salt: Uint8Array,
|
||||
credentialIds: Array<string | null | undefined> = []
|
||||
): Record<string, unknown> {
|
||||
const evalInput = { first: salt };
|
||||
const evalByCredential = credentialIds
|
||||
.filter((id): id is string => !!id)
|
||||
.reduce<Record<string, typeof evalInput>>((out, id) => {
|
||||
out[id] = evalInput;
|
||||
return out;
|
||||
}, {});
|
||||
return {
|
||||
prf: {
|
||||
eval: evalInput,
|
||||
...(Object.keys(evalByCredential).length ? { evalByCredential } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function prfCredentialIdsFromAllowCredentials(options: PublicKeyCredentialRequestOptions): string[] {
|
||||
return (options.allowCredentials || [])
|
||||
.map((credential) => credentialIdToBase64Url(credential.id))
|
||||
.filter((id): id is string => !!id);
|
||||
}
|
||||
|
||||
async function prfOutputToKey(prfOutput: ArrayBuffer): Promise<Uint8Array> {
|
||||
const prf = new Uint8Array(prfOutput);
|
||||
const enc = await hkdfExpand(prf, 'enc', 32);
|
||||
const mac = await hkdfExpand(prf, 'mac', 32);
|
||||
const out = new Uint8Array(64);
|
||||
out.set(enc, 0);
|
||||
out.set(mac, 32);
|
||||
return out;
|
||||
}
|
||||
|
||||
function publicKeyCredentialBase(credential: PublicKeyCredential): Record<string, unknown> {
|
||||
return {
|
||||
id: credential.id,
|
||||
rawId: bytesToBase64Url(new Uint8Array(credential.rawId)),
|
||||
type: credential.type,
|
||||
extensions: {},
|
||||
};
|
||||
}
|
||||
|
||||
function assertionRequest(credential: PublicKeyCredential): Record<string, unknown> {
|
||||
if (!(credential.response instanceof AuthenticatorAssertionResponse)) {
|
||||
throw new Error('Invalid passkey assertion response');
|
||||
}
|
||||
return {
|
||||
...publicKeyCredentialBase(credential),
|
||||
response: {
|
||||
authenticatorData: bytesToBase64Url(new Uint8Array(credential.response.authenticatorData)),
|
||||
signature: bytesToBase64Url(new Uint8Array(credential.response.signature)),
|
||||
clientDataJSON: bytesToBase64Url(new Uint8Array(credential.response.clientDataJSON)),
|
||||
userHandle: credential.response.userHandle
|
||||
? bytesToBase64Url(new Uint8Array(credential.response.userHandle))
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function attestationRequest(credential: PublicKeyCredential): Record<string, unknown> {
|
||||
if (!(credential.response instanceof AuthenticatorAttestationResponse)) {
|
||||
throw new Error('Invalid passkey registration response');
|
||||
}
|
||||
const transports = typeof credential.response.getTransports === 'function'
|
||||
? credential.response.getTransports()
|
||||
: undefined;
|
||||
return {
|
||||
...publicKeyCredentialBase(credential),
|
||||
response: {
|
||||
attestationObject: bytesToBase64Url(new Uint8Array(credential.response.attestationObject)),
|
||||
clientDataJson: bytesToBase64Url(new Uint8Array(credential.response.clientDataJSON)),
|
||||
transports,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function assertAccountPasskey(
|
||||
response: { options: unknown; token: string }
|
||||
): Promise<AccountPasskeyAssertion> {
|
||||
if (!window.PublicKeyCredential || !navigator.credentials) {
|
||||
throw new Error('Passkey is not supported in this browser');
|
||||
}
|
||||
const nativeOptions = cloneRequestOptions(response.options);
|
||||
(nativeOptions as any).extensions = {
|
||||
...((nativeOptions as any).extensions || {}),
|
||||
...buildPrfExtension(await getLoginWithPrfSalt(), prfCredentialIdsFromAllowCredentials(nativeOptions)),
|
||||
};
|
||||
const credential = await navigator.credentials.get({ publicKey: nativeOptions });
|
||||
if (!(credential instanceof PublicKeyCredential)) {
|
||||
throw new Error('No passkey was selected');
|
||||
}
|
||||
const prfResult = (credential.getClientExtensionResults() as any).prf?.results?.first;
|
||||
return {
|
||||
token: response.token,
|
||||
deviceResponse: assertionRequest(credential),
|
||||
prfKey: prfResult ? await prfOutputToKey(prfResult) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createAccountPasskeyCredential(
|
||||
response: { options: unknown; token: string }
|
||||
): Promise<PendingAccountPasskeyCredential> {
|
||||
if (!window.PublicKeyCredential || !navigator.credentials) {
|
||||
throw new Error('Passkey is not supported in this browser');
|
||||
}
|
||||
const nativeOptions = cloneCreationOptions(response.options);
|
||||
(nativeOptions as any).extensions = {
|
||||
...((nativeOptions as any).extensions || {}),
|
||||
prf: {},
|
||||
};
|
||||
const credential = await navigator.credentials.create({ publicKey: nativeOptions });
|
||||
if (!(credential instanceof PublicKeyCredential)) {
|
||||
throw new Error('No passkey was created');
|
||||
}
|
||||
const supportsPrf = !!(credential.getClientExtensionResults() as any).prf?.enabled;
|
||||
return {
|
||||
token: response.token,
|
||||
createOptions: nativeOptions,
|
||||
deviceResponse: credential,
|
||||
request: attestationRequest(credential),
|
||||
supportsPrf,
|
||||
};
|
||||
}
|
||||
|
||||
function parseRsaEncryptedUserKey(value: string): Uint8Array {
|
||||
const text = String(value || '').trim();
|
||||
const [type, payload] = text.split('.');
|
||||
if (type !== '4' || !payload) throw new Error('Unsupported encrypted user key');
|
||||
return base64ToBytes(payload);
|
||||
}
|
||||
|
||||
export async function buildAccountPasskeyPrfKeySet(
|
||||
pending: PendingAccountPasskeyCredential,
|
||||
userKey: { symEncKey: string; symMacKey: string }
|
||||
): Promise<AccountPasskeyPrfKeySet> {
|
||||
const rawId = new Uint8Array(pending.deviceResponse.rawId);
|
||||
const credentialId = bytesToBase64Url(rawId);
|
||||
const assertionOptions: PublicKeyCredentialRequestOptions = {
|
||||
challenge: pending.createOptions?.challenge!,
|
||||
rpId: pending.createOptions?.rp?.id,
|
||||
allowCredentials: [{ id: toArrayBuffer(rawId), type: 'public-key' }],
|
||||
timeout: pending.createOptions?.timeout,
|
||||
userVerification: pending.createOptions?.authenticatorSelection?.userVerification,
|
||||
};
|
||||
(assertionOptions as any).extensions = {
|
||||
...buildPrfExtension(await getLoginWithPrfSalt(), [credentialId]),
|
||||
};
|
||||
const assertion = await navigator.credentials.get({ publicKey: assertionOptions });
|
||||
if (!(assertion instanceof PublicKeyCredential)) {
|
||||
throw new Error('Passkey verification failed');
|
||||
}
|
||||
const prfResult = (assertion.getClientExtensionResults() as any).prf?.results?.first;
|
||||
if (!prfResult) {
|
||||
throw new Error('This passkey does not support direct vault unlock');
|
||||
}
|
||||
return buildAccountPasskeyPrfKeySetFromPrfKey(await prfOutputToKey(prfResult), userKey);
|
||||
}
|
||||
|
||||
export async function buildAccountPasskeyPrfKeySetFromPrfKey(
|
||||
prfKey: Uint8Array,
|
||||
userKey: { symEncKey: string; symMacKey: string }
|
||||
): Promise<AccountPasskeyPrfKeySet> {
|
||||
const userKeyBytes = new Uint8Array(64);
|
||||
userKeyBytes.set(base64ToBytes(userKey.symEncKey), 0);
|
||||
userKeyBytes.set(base64ToBytes(userKey.symMacKey), 32);
|
||||
|
||||
const pair = await crypto.subtle.generateKey(
|
||||
{
|
||||
name: 'RSA-OAEP',
|
||||
modulusLength: 2048,
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
hash: 'SHA-1',
|
||||
},
|
||||
true,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
const publicKey = new Uint8Array(await crypto.subtle.exportKey('spki', pair.publicKey));
|
||||
const privateKey = new Uint8Array(await crypto.subtle.exportKey('pkcs8', pair.privateKey));
|
||||
const encryptedUserKeyBytes = new Uint8Array(await crypto.subtle.encrypt(
|
||||
{ name: 'RSA-OAEP' },
|
||||
pair.publicKey,
|
||||
toBufferSource(userKeyBytes)
|
||||
));
|
||||
|
||||
return {
|
||||
encryptedUserKey: `4.${bytesToBase64(encryptedUserKeyBytes)}`,
|
||||
encryptedPublicKey: await encryptBw(publicKey, userKeyBytes.slice(0, 32), userKeyBytes.slice(32, 64)),
|
||||
encryptedPrivateKey: await encryptBw(privateKey, prfKey.slice(0, 32), prfKey.slice(32, 64)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function unlockVaultKeyWithAccountPasskeyPrf(
|
||||
prfKey: Uint8Array,
|
||||
option: AccountPasskeyPrfOption
|
||||
): Promise<{ symEncKey: string; symMacKey: string }> {
|
||||
const encryptedPrivateKey = option.EncryptedPrivateKey || option.encryptedPrivateKey || '';
|
||||
const encryptedUserKey = option.EncryptedUserKey || option.encryptedUserKey || '';
|
||||
if (!encryptedPrivateKey || !encryptedUserKey) {
|
||||
throw new Error('Passkey cannot unlock this vault');
|
||||
}
|
||||
const privateKeyBytes = await decryptBw(encryptedPrivateKey, prfKey.slice(0, 32), prfKey.slice(32, 64));
|
||||
const privateKey = await crypto.subtle.importKey(
|
||||
'pkcs8',
|
||||
toBufferSource(privateKeyBytes),
|
||||
{ name: 'RSA-OAEP', hash: 'SHA-1' },
|
||||
false,
|
||||
['decrypt']
|
||||
);
|
||||
const userKeyBytes = new Uint8Array(await crypto.subtle.decrypt(
|
||||
{ name: 'RSA-OAEP' },
|
||||
privateKey,
|
||||
toBufferSource(parseRsaEncryptedUserKey(encryptedUserKey))
|
||||
));
|
||||
if (userKeyBytes.length < 64) throw new Error('Invalid passkey vault key');
|
||||
return {
|
||||
symEncKey: bytesToBase64(userKeyBytes.slice(0, 32)),
|
||||
symMacKey: bytesToBase64(userKeyBytes.slice(32, 64)),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user