Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 86 additions & 7 deletions src/background/account/Account.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import type { EventsMap } from 'nanoevents';
import { createNanoEvents } from 'nanoevents';
import { nanoid } from 'nanoid';
import { createSalt, createCryptoKey } from 'src/modules/crypto';
import {
createSalt,
createCryptoKey,
encrypt,
decrypt,
} from 'src/modules/crypto';
import { getSHA256HexDigest } from 'src/modules/crypto/getSHA256HexDigest';
import {
BrowserStorage,
Expand All @@ -10,9 +15,11 @@ import {
} from 'src/background/webapis/storage';
import { validate } from 'src/shared/validation/user-input';
import { eraseAndUpdateToLatestVersion } from 'src/shared/core/version/shared';
import { currentUserKey } from 'src/shared/getCurrentUser';
import type { PublicUser, User } from 'src/shared/types/User';
import { currentUserKey, getCurrentUser } from 'src/shared/getCurrentUser';
import type { Passkey, PublicUser, User } from 'src/shared/types/User';
import { payloadId } from '@walletconnect/jsonrpc-utils';
import { sha256 } from 'src/modules/crypto/sha256';
import { produce } from 'immer';
import { Wallet } from '../Wallet/Wallet';
import { peakSavedWalletState } from '../Wallet/persistence';
import type { NotificationWindow } from '../NotificationWindow/NotificationWindow';
Expand All @@ -21,10 +28,6 @@ import { credentialsKey } from './storage-keys';

const TEMPORARY_ID = 'temporary';

async function sha256({ password, salt }: { password: string; salt: string }) {
return await getSHA256HexDigest(`${salt}:${password}`);
}

class EventEmitter<Events extends EventsMap> {
private emitter = createNanoEvents<Events>();

Expand Down Expand Up @@ -77,6 +80,34 @@ export class Account extends EventEmitter<AccountEvents> {
await BrowserStorage.remove(currentUserKey);
}

async getEncryptedPassword() {
return (await Account.readCurrentUser())?.passkey ?? null;
}

async setEncryptedPassword(passkey: Passkey) {
const user = await getCurrentUser();
if (!user) {
throw new Error('No user found');
}
await Account.writeCurrentUser(
produce(user, (draft) => {
draft.passkey = passkey;
})
);
}

async removeEncryptedPassword() {
const user = await Account.readCurrentUser();
if (!user) {
throw new Error('No user found');
}
await Account.writeCurrentUser(
produce(user, (draft) => {
draft.passkey = null;
})
);
}

private static async writeCredentials(credentials: Credentials) {
const preferences = await globalPreferences.getPreferences();
if (preferences.autoLockTimeout === 'none') {
Expand Down Expand Up @@ -395,4 +426,52 @@ export class AccountPublicRPC {
await eraseAndUpdateToLatestVersion();
await this.account.logout(); // reset account after erasing storage
}

async setPasskey({
params: { encryptionKey, password, salt, id },
}: PublicMethodParams<{
encryptionKey: string;
password: string;
salt: string;
id: string;
}>) {
const encrypted = await encrypt(encryptionKey, { password });
return this.account.setEncryptedPassword({
encryptedPassword: encrypted,
salt,
id,
});
}

async getPasskeyMeta() {
const data = await this.account.getEncryptedPassword();
if (!data) {
throw new Error('No passkey found');
}
const { id, salt } = data;
return { id, salt };
}

async getPassword({
params: { encryptionKey },
}: PublicMethodParams<{ encryptionKey: string }>) {
const data = await this.account.getEncryptedPassword();
if (!data) {
throw new Error('No passkey found');
}
const decrypted = await decrypt<{ password: string }>(
encryptionKey,
data.encryptedPassword
);
return decrypted.password;
}

async getPasskeyEnabled(): Promise<boolean> {
const data = await this.account.getEncryptedPassword();
return Boolean(data);
}

async removePasskey() {
return this.account.removeEncryptedPassword();
}
}
86 changes: 86 additions & 0 deletions src/modules/crypto/hkdf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { utf8ToUint8Array } from './convert';

/**
* HKDF (HMAC-based Key Derivation Function) implementation using Web Crypto API
* RFC 5869: https://tools.ietf.org/html/rfc5869
*
* This provides a cryptographically secure way to derive keys from high-entropy input
* (like PRF output) without the computational cost of PBKDF2.
*/

/**
* Derives a key using HKDF-SHA256
*
* @param ikm - Input Key Material (the high-entropy source, e.g., PRF output)
* @param salt - Salt value (should be random and unique per credential)
* @param info - Optional context and application specific information
* @param length - Desired output length in bytes (default: 32 for SHA-256)
* @returns Derived key as hex string
*/
async function hkdf({
ikm,
salt,
info,
length = 32,
}: {
ikm: string | ArrayBuffer;
salt: string;
info: string;
length?: number;
}): Promise<string> {
// Convert inputs to Uint8Array
const ikmArray =
typeof ikm === 'string' ? utf8ToUint8Array(ikm) : new Uint8Array(ikm);
const saltArray = utf8ToUint8Array(salt);
const infoArray = utf8ToUint8Array(info);

// Import IKM as a CryptoKey for HKDF
const ikmKey = await crypto.subtle.importKey(
'raw',
ikmArray,
{ name: 'HKDF' },
false,
['deriveBits']
);

// Derive bits using HKDF
const derivedBits = await crypto.subtle.deriveBits(
{
name: 'HKDF',
hash: 'SHA-256',
salt: saltArray,
info: infoArray,
},
ikmKey,
length * 8 // Convert bytes to bits
);

// Convert to hex string for consistency with existing sha256 function
const hashArray = Array.from(new Uint8Array(derivedBits));
const hashHex = hashArray
.map((b) => b.toString(16).padStart(2, '0'))
.join('');

return hashHex;
}

/**
* Derives an encryption key from PRF output using HKDF
* This adds defense-in-depth by ensuring even if the salt is compromised,
* the attacker still needs the PRF output from the authenticator.
*
* @param prfOutput - The PRF output from WebAuthn authenticator
* @param salt - Random salt (stored with the passkey)
* @returns Encryption key as hex string
*/
export async function deriveEncryptionKeyFromPRF(
prfOutput: ArrayBuffer,
salt: string
): Promise<string> {
return hkdf({
ikm: prfOutput,
salt,
length: 32,
info: 'zerion-passkey-v1',
});
}
1 change: 1 addition & 0 deletions src/modules/crypto/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ export {
export { createSalt, createCryptoKey } from './key';
export { encrypt, decrypt } from './aes';
export { stableEncrypt, stableDecrypt } from './aesStable';
export { deriveEncryptionKeyFromPRF } from './hkdf';
11 changes: 11 additions & 0 deletions src/modules/crypto/sha256.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { getSHA256HexDigest } from './getSHA256HexDigest';

export async function sha256({
password,
salt,
}: {
password: string;
salt: string;
}) {
return await getSHA256HexDigest(`${salt}:${password}`);
}
6 changes: 6 additions & 0 deletions src/shared/types/User.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
export type Passkey = {
encryptedPassword: string;
salt: string;
id: string;
};
export interface User {
id: string;
salt: string;
passkey?: Passkey | null;
}

export interface PublicUser {
Expand Down
3 changes: 3 additions & 0 deletions src/ui/assets/touch-id.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading