UNPKG

@evolu/web

Version:
194 lines (193 loc) 8.57 kB
import { set, get, del, keys, clear, createStore } from "idb-keyval"; import { createSlip21, utf8ToBytes, bytesToUtf8, base64UrlToUint8Array, uint8ArrayToBase64Url, EncryptionKey, Base64Url, } from "@evolu/common"; /** @experimental */ export const createWebAuthnStore = (deps) => ({ setItem: async (key, value, options) => { if (options?.accessControl === "none") { const metadata = createMetadata(false); await set(key, { value, metadata }, getStore(options.service)); return { metadata }; } const seed = generateSeed(deps)(); const authResult = JSON.parse(value); const credential = await createCredential(deps)(options?.webAuthnUsername ?? "Evolu User", seed, options?.relyingPartyID, options?.relyingPartyName, options?.webAuthnUserVerification, options?.webAuthnAuthenticatorAttachment); const encryptionKey = deriveEncryptionKey(seed); const encryptedData = encryptAuthResult(deps)(authResult, encryptionKey); const credentialId = uint8ArrayToBase64Url(new Uint8Array(credential.rawId)); const metadata = createMetadata(); await set(key, { credentialId, ...encryptedData, metadata }, getStore(options?.service)); return { metadata }; }, getItem: async (key, options) => { if (options?.accessControl === "none") { const data = await get(key, getStore(options.service)); return data ? { key, value: data.value, service: options.service ?? "default", metadata: data.metadata, } : null; } const data = await get(key, getStore(options?.service)); if (!data) { return null; } try { const credential = await getCredential(deps)(data.credentialId, options?.relyingPartyID, options?.webAuthnUserVerification); const credentialSeed = extractSeedFromCredential(credential); const encryptionKey = deriveEncryptionKey(credentialSeed); const authResultVal = decryptAuthResult(deps)(data, encryptionKey); if (!authResultVal) { return null; } return { key, service: options?.service ?? "default", value: authResultVal, metadata: data.metadata, }; } catch (_error) { return null; } }, deleteItem: async (key, options) => { await del(key, getStore(options?.service)); return true; }, getAllItems: async (options) => { const service = options?.service ?? "default"; const itemKeys = await keys(getStore(service)); const items = await Promise.all(itemKeys.map(async (key) => { const data = await get(key, getStore(service)); return { key, service, metadata: data?.metadata ?? createMetadata(), ...(options?.includeValues && data?.value ? { value: data.value } : {}), }; })); return items; }, clearService: async (options) => { await clear(getStore(options?.service)); }, }); /** * Create default metadata for backwards compatibility with items that don't * have stored metadata. */ const createMetadata = (isSecure = true) => { return { backend: "keychain", accessControl: isSecure ? "biometryCurrentSet" : "none", securityLevel: isSecure ? "biometry" : "software", timestamp: Date.now(), }; }; /** Get storage key for owner ID. (supports namespaces via prefix) */ const getStore = (prefix = "default") => { return createStore(prefix, "evolu-auth"); }; const createCredential = (deps) => async (username, seed, relyingPartyID, relyingPartyName, userVerification, authenticatorAttachment) => { const options = createCredentialCreationOptions(deps)(username, seed, relyingPartyID, relyingPartyName, userVerification, authenticatorAttachment); const credential = (await navigator.credentials.create(options)); if (!credential) { throw new Error("Failed to create WebAuthn credential"); } return credential; }; const getCredential = (deps) => async (credentialId, relyingPartyID, userVerification) => { const options = createCredentialRequestOptions(deps)(credentialId, relyingPartyID, userVerification); const credential = (await navigator.credentials.get(options)); if (!credential?.response) { throw new Error("Failed to get WebAuthn credential"); } return credential; }; const extractSeedFromCredential = (credential) => { const response = credential.response; if (!response.userHandle) { throw new Error("No userHandle in credential response"); } return new Uint8Array(response.userHandle); }; const createCredentialCreationOptions = (deps) => (username, seed, relyingPartyID, relyingPartyName, userVerification, authenticatorAttachment) => { return { publicKey: { challenge: generateSeed(deps)(), rp: { id: relyingPartyID ?? document.location.hostname, name: relyingPartyName ?? "Evolu", }, user: { id: seed, name: username, displayName: username, }, pubKeyCredParams: [ { type: "public-key", alg: -8 }, // Ed25519 { type: "public-key", alg: -7 }, // ES256 { type: "public-key", alg: -257 }, // RS256 ], attestation: "none", authenticatorSelection: { // - "platform": Uses the platform's built-in authenticator. // - "cross-platform": Uses a device specific authenticator (yubikey, fido2, etc.) authenticatorAttachment: authenticatorAttachment ?? "platform", // - "discouraged": Only User Presence is needed. // - "preferred": User Verification is preferred but not required. Falls back to User Presence. // - "required": User Verification MUST occur (biometrics/PIN). Clients may silently downgrade to User Presence only. userVerification: userVerification ?? "required", // - "discouraged": Server-side credential is preferable, but will accept client-side discoverable credential. // - "preferred": Relying Party strongly prefers client-side discoverable credential but will accept server-side credential. // - "required": Client-side discoverable credential MUST be created, error if it can't be created. residentKey: "required", // Included for backwards compatibility. Deprecated in favor of residentKey (true = "required") requireResidentKey: true, }, }, }; }; const createCredentialRequestOptions = (deps) => (credentialId, relyingPartyID, userVerification) => { return { publicKey: { challenge: generateSeed(deps)(), rpId: relyingPartyID ?? document.location.hostname, userVerification: userVerification ?? "preferred", allowCredentials: [ { type: "public-key", id: base64UrlToUint8Array(Base64Url.orThrow(credentialId)), }, ], }, }; }; const deriveEncryptionKey = (seed) => { const seed32 = seed.length === 32 ? seed : seed.slice(0, 32); return EncryptionKey.orThrow(createSlip21(seed32, ["evolu", "auth"])); }; const encryptAuthResult = (deps) => (authResult, encryptionKey) => { const plaintext = utf8ToBytes(JSON.stringify(authResult)); const { nonce, ciphertext } = deps.symmetricCrypto.encrypt(plaintext, encryptionKey); return { nonce: uint8ArrayToBase64Url(nonce), ciphertext: uint8ArrayToBase64Url(ciphertext), }; }; const decryptAuthResult = (deps) => (encryptedData, encryptionKey) => { const nonce = base64UrlToUint8Array(encryptedData.nonce); const ciphertext = base64UrlToUint8Array(encryptedData.ciphertext); const result = deps.symmetricCrypto.decrypt(ciphertext, encryptionKey, nonce); if (!result.ok) return null; return bytesToUtf8(result.value); }; const generateSeed = (deps) => () => { return deps.randomBytes.create(32); };