diff options
| author | Adam Soltys <[email protected]> | 2026-02-16 13:17:43 -0800 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-02-16 13:17:43 -0800 |
| commit | ac06683100e089ad589661b14ebaf5dc9582a256 (patch) | |
| tree | f561f90aa09f3a6a3747cd703984043f028f9067 /src/lib | |
| parent | e39bf4f977bba2952a7de4a307b14c5ec862a3a7 (diff) | |
| download | coinos-ui-ac06683100e089ad589661b14ebaf5dc9582a256.tar.gz coinos-ui-ac06683100e089ad589661b14ebaf5dc9582a256.zip | |
passkey tweaks
Diffstat (limited to 'src/lib')
| -rw-r--r-- | src/lib/crypto.ts | 60 | ||||
| -rw-r--r-- | src/lib/passkey.ts | 26 | ||||
| -rw-r--r-- | src/lib/passwordCache.ts | 42 |
3 files changed, 123 insertions, 5 deletions
diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts new file mode 100644 index 00000000..d941d894 --- /dev/null +++ b/src/lib/crypto.ts @@ -0,0 +1,60 @@ +export function isPrfEncrypted(value: string): boolean { + return value.startsWith("{"); +} + +async function importPrfKey( + prfKey: ArrayBuffer, + usages: KeyUsage[], +): Promise<CryptoKey> { + return crypto.subtle.importKey("raw", prfKey, "AES-GCM", false, usages); +} + +export async function prfEncrypt( + prfKey: ArrayBuffer, + plaintext: Uint8Array, +): Promise<string> { + const key = await importPrfKey(prfKey, ["encrypt"]); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = new Uint8Array( + await crypto.subtle.encrypt( + { name: "AES-GCM", iv: iv.buffer as ArrayBuffer }, + key, + (plaintext as Uint8Array).buffer as ArrayBuffer, + ), + ); + return JSON.stringify({ + v: 1, + iv: toBase64Url(iv), + ct: toBase64Url(ciphertext), + }); +} + +export async function prfDecrypt( + prfKey: ArrayBuffer, + encrypted: string, +): Promise<Uint8Array> { + const { iv, ct } = JSON.parse(encrypted); + const key = await importPrfKey(prfKey, ["decrypt"]); + const ivBytes = fromBase64Url(iv); + const ctBytes = fromBase64Url(ct); + const plaintext = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: ivBytes.buffer as ArrayBuffer }, + key, + ctBytes.buffer as ArrayBuffer, + ); + return new Uint8Array(plaintext); +} + +function toBase64Url(bytes: Uint8Array): string { + let binary = ""; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function fromBase64Url(s: string): Uint8Array { + const padded = s.replace(/-/g, "+").replace(/_/g, "/") + "==".slice(0, (4 - (s.length % 4)) % 4); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} diff --git a/src/lib/passkey.ts b/src/lib/passkey.ts index 228b254e..053eb06a 100644 --- a/src/lib/passkey.ts +++ b/src/lib/passkey.ts @@ -1,14 +1,30 @@ import { startRegistration, startAuthentication } from "@simplewebauthn/browser"; import { post } from "$lib/utils"; -export async function registerPasskey() { - const options = await post("/passkey/register/options", {}); +async function deriveKey(credentialId: string): Promise<ArrayBuffer> { + const data = new TextEncoder().encode("coinos-wallet-key:" + credentialId); + return crypto.subtle.digest("SHA-256", data); +} + +function authHeader(token: string) { + return { authorization: `Bearer ${token}` }; +} + +export async function registerPasskey(token?: string): Promise<ArrayBuffer> { + const headers = token ? authHeader(token) : undefined; + const options = await post("/passkey/register/options", {}, headers); const registration = await startRegistration({ optionsJSON: options }); - await post("/passkey/register/verify", registration); + await post("/passkey/register/verify", registration, headers); + return deriveKey(registration.id); } -export async function loginWithPasskey() { +export async function loginWithPasskey(): Promise<{ + credential: any; + challengeId: string; + prfKey: ArrayBuffer; +}> { const { challengeId, ...options } = await post("/passkey/login/options", {}); const authentication = await startAuthentication({ optionsJSON: options }); - return { credential: authentication, challengeId }; + const prfKey = await deriveKey(authentication.id); + return { credential: authentication, challengeId, prfKey }; } diff --git a/src/lib/passwordCache.ts b/src/lib/passwordCache.ts index 5a2a4796..98272e0f 100644 --- a/src/lib/passwordCache.ts +++ b/src/lib/passwordCache.ts @@ -59,3 +59,45 @@ export const getRememberedWalletPassword = () => { walletPassword.set(password); return password; }; + +// PRF key cache +const PRF_KEY = "prfKey"; +const PRF_EXPIRES_KEY = "prfKeyExpiresAt"; + +function hexFromBuffer(buf: ArrayBuffer): string { + return Array.from(new Uint8Array(buf)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +function bufferFromHex(hex: string): ArrayBuffer { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16); + return bytes.buffer; +} + +export const rememberPrfKey = (key: ArrayBuffer, durationMs: number) => { + if (!browser) return; + const expiresAt = Date.now() + durationMs; + localStorage.setItem(PRF_KEY, hexFromBuffer(key)); + localStorage.setItem(PRF_EXPIRES_KEY, String(expiresAt)); +}; + +export const getCachedPrfKey = (): ArrayBuffer | null => { + if (!browser) return null; + const hex = localStorage.getItem(PRF_KEY); + if (!hex) return null; + const expiresAtRaw = localStorage.getItem(PRF_EXPIRES_KEY); + const expiresAt = expiresAtRaw ? Number(expiresAtRaw) : 0; + if (expiresAt && Date.now() > expiresAt) { + forgetPrfKey(); + return null; + } + return bufferFromHex(hex); +}; + +export const forgetPrfKey = () => { + if (!browser) return; + localStorage.removeItem(PRF_KEY); + localStorage.removeItem(PRF_EXPIRES_KEY); +}; |
