aboutsummaryrefslogtreecommitdiffstats
path: root/src/lib
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-04-08 22:33:43 -0700
committerAdam Soltys <[email protected]>2026-04-08 22:57:55 -0700
commit757a59c5c9c4b56120446cae1ac495773b9905c8 (patch)
tree58ae7c3e3f11fb32164dae59b6e952e54dc6ac93 /src/lib
parentc7136dbefa4da50297513955cf51084afa23d98c (diff)
downloadcoinos-ui-staging.tar.gz
coinos-ui-staging.zip
breez style passkey derivationstaging
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/keyWrapping.ts17
-rw-r--r--src/lib/passkey.ts110
2 files changed, 116 insertions, 11 deletions
diff --git a/src/lib/keyWrapping.ts b/src/lib/keyWrapping.ts
index 31cb1e5c..cd68947f 100644
--- a/src/lib/keyWrapping.ts
+++ b/src/lib/keyWrapping.ts
@@ -28,9 +28,20 @@ export async function resolveCanonicalKey(
oldPrfKey: ArrayBuffer,
): Promise<{ canonicalKey: ArrayBuffer; encryptedKeys: Record<string, string> }> {
if (encryptedKeys && encryptedKeys[methodId]) {
- const canonicalKey = await unwrapCanonicalKey(wrappingKey, encryptedKeys[methodId]);
- rememberPrfKey(canonicalKey, defaultRememberForMs);
- return { canonicalKey, encryptedKeys };
+ try {
+ // Try new wrapping key first (e.g. PRF-derived)
+ const canonicalKey = await unwrapCanonicalKey(wrappingKey, encryptedKeys[methodId]);
+ rememberPrfKey(canonicalKey, defaultRememberForMs);
+ return { canonicalKey, encryptedKeys };
+ } catch {
+ // Fallback: try old key (e.g. SHA-256(credentialId)) and re-wrap with new key
+ const canonicalKey = await unwrapCanonicalKey(oldPrfKey, encryptedKeys[methodId]);
+ const wrapped = await wrapCanonicalKey(wrappingKey, canonicalKey);
+ const updated = { ...encryptedKeys, [methodId]: wrapped };
+ await saveEncryptedKeys(updated);
+ rememberPrfKey(canonicalKey, defaultRememberForMs);
+ return { canonicalKey, encryptedKeys: updated };
+ }
}
// Migration: old PRF key becomes canonical key
diff --git a/src/lib/passkey.ts b/src/lib/passkey.ts
index 3d7b7272..8e73144b 100644
--- a/src/lib/passkey.ts
+++ b/src/lib/passkey.ts
@@ -1,35 +1,129 @@
-import { startRegistration, startAuthentication } from "@simplewebauthn/browser";
+import { startRegistration } from "@simplewebauthn/browser";
import { post } from "$lib/utils";
-async function deriveKey(credentialId: string): Promise<ArrayBuffer> {
+const PRF_SALT = new TextEncoder().encode("coinos-wallet-key");
+
+function bufferToBase64url(buffer: ArrayBuffer): string {
+ const bytes = new Uint8Array(buffer);
+ let binary = "";
+ for (const b of bytes) binary += String.fromCharCode(b);
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+}
+
+function base64urlToBuffer(base64url: string): ArrayBuffer {
+ const padded =
+ base64url.replace(/-/g, "+").replace(/_/g, "/") +
+ "==".slice(0, (4 - (base64url.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.buffer as ArrayBuffer;
+}
+
+async function deriveKeyLegacy(credentialId: string): Promise<ArrayBuffer> {
const data = new TextEncoder().encode("coinos-wallet-key:" + credentialId);
return crypto.subtle.digest("SHA-256", data);
}
+async function evaluatePrf(credentialId: string): Promise<ArrayBuffer | null> {
+ try {
+ const assertion = (await navigator.credentials.get({
+ publicKey: {
+ challenge: crypto.getRandomValues(new Uint8Array(32)),
+ allowCredentials: [
+ { id: base64urlToBuffer(credentialId), type: "public-key" as const },
+ ],
+ extensions: { prf: { eval: { first: PRF_SALT } } } as any,
+ userVerification: "required",
+ },
+ })) as PublicKeyCredential;
+
+ const results = (assertion.getClientExtensionResults() as any).prf?.results;
+ return results?.first || null;
+ } catch {
+ return null;
+ }
+}
+
function authHeader(token: string) {
return { authorization: `Bearer ${token}` };
}
export async function registerPasskey(
token?: string,
-): Promise<{ prfKey: ArrayBuffer; credentialId: string }> {
+): Promise<{ prfKey: ArrayBuffer; credentialId: string; prfSupported: boolean }> {
const headers = token ? authHeader(token) : undefined;
const origin = window.location.origin;
const options = await post("/passkey/register/options", { origin }, headers);
const registration = await startRegistration({ optionsJSON: options });
await post("/passkey/register/verify", { ...registration, origin }, headers);
- const prfKey = await deriveKey(registration.id);
- return { prfKey, credentialId: registration.id };
+
+ // Try PRF evaluation with an immediate get() after registration
+ const prfResult = await evaluatePrf(registration.id);
+ if (prfResult) {
+ return { prfKey: prfResult, credentialId: registration.id, prfSupported: true };
+ }
+
+ // Fallback to legacy SHA-256(credentialId)
+ const legacyKey = await deriveKeyLegacy(registration.id);
+ return { prfKey: legacyKey, credentialId: registration.id, prfSupported: false };
}
export async function loginWithPasskey(): Promise<{
credential: any;
challengeId: string;
prfKey: ArrayBuffer;
+ legacyKey: ArrayBuffer;
}> {
const origin = window.location.origin;
const { challengeId, ...options } = await post("/passkey/login/options", { origin });
- const authentication = await startAuthentication({ optionsJSON: options });
- const prfKey = await deriveKey(authentication.id);
- return { credential: authentication, challengeId, prfKey };
+
+ // Convert options from JSON to WebAuthn API format
+ const publicKeyOptions: PublicKeyCredentialRequestOptions = {
+ challenge: base64urlToBuffer(options.challenge),
+ rpId: options.rpId,
+ timeout: options.timeout,
+ userVerification: (options.userVerification as UserVerificationRequirement) || "required",
+ allowCredentials: options.allowCredentials?.map((c: any) => ({
+ id: base64urlToBuffer(c.id),
+ type: c.type as PublicKeyCredentialType,
+ transports: c.transports,
+ })),
+ extensions: {
+ prf: { eval: { first: PRF_SALT } },
+ } as any,
+ };
+
+ const assertion = (await navigator.credentials.get({
+ publicKey: publicKeyOptions,
+ })) as PublicKeyCredential;
+
+ const response = assertion.response as AuthenticatorAssertionResponse;
+
+ // Serialize response in the format the server expects (same as simplewebauthn)
+ const credential = {
+ id: assertion.id,
+ rawId: bufferToBase64url(assertion.rawId),
+ response: {
+ authenticatorData: bufferToBase64url(response.authenticatorData),
+ clientDataJSON: bufferToBase64url(response.clientDataJSON),
+ signature: bufferToBase64url(response.signature),
+ userHandle: response.userHandle ? bufferToBase64url(response.userHandle) : null,
+ },
+ type: assertion.type,
+ clientExtensionResults: {},
+ authenticatorAttachment: (assertion as any).authenticatorAttachment || null,
+ };
+
+ // Extract PRF result
+ const prfResults = (assertion.getClientExtensionResults() as any).prf?.results;
+ const prfOutput: ArrayBuffer | null = prfResults?.first || null;
+
+ // Compute legacy key for migration
+ const legacyKey = await deriveKeyLegacy(assertion.id);
+
+ // Use PRF output if available, otherwise fall back to legacy
+ const prfKey = prfOutput || legacyKey;
+
+ return { credential, challengeId, prfKey, legacyKey };
}