aboutsummaryrefslogtreecommitdiffstats
path: root/src/lib
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-02-16 13:17:43 -0800
committerAdam Soltys <[email protected]>2026-02-16 13:17:43 -0800
commitac06683100e089ad589661b14ebaf5dc9582a256 (patch)
treef561f90aa09f3a6a3747cd703984043f028f9067 /src/lib
parente39bf4f977bba2952a7de4a307b14c5ec862a3a7 (diff)
downloadcoinos-ui-ac06683100e089ad589661b14ebaf5dc9582a256.tar.gz
coinos-ui-ac06683100e089ad589661b14ebaf5dc9582a256.zip
passkey tweaks
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/crypto.ts60
-rw-r--r--src/lib/passkey.ts26
-rw-r--r--src/lib/passwordCache.ts42
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);
+};