diff --git a/src/backend/accounts/crypto.ts b/src/backend/accounts/crypto.ts index 62ba6d3f..ff847522 100644 --- a/src/backend/accounts/crypto.ts +++ b/src/backend/accounts/crypto.ts @@ -4,6 +4,12 @@ import { generateMnemonic, validateMnemonic } from "@scure/bip39"; import { wordlist } from "@scure/bip39/wordlists/english"; import forge from "node-forge"; +type Keys = { + privateKey: Uint8Array; + publicKey: Uint8Array; + seed: Uint8Array; +}; + async function seedFromMnemonic(mnemonic: string) { return pbkdf2Async(sha256, mnemonic, "mnemonic", { c: 2048, @@ -15,7 +21,7 @@ export function verifyValidMnemonic(mnemonic: string) { return validateMnemonic(mnemonic, wordlist); } -export async function keysFromMnemonic(mnemonic: string) { +export async function keysFromMnemonic(mnemonic: string): Promise { const seed = await seedFromMnemonic(mnemonic); const { privateKey, publicKey } = forge.pki.ed25519.generateKeyPair({ @@ -25,6 +31,7 @@ export async function keysFromMnemonic(mnemonic: string) { return { privateKey, publicKey, + seed, }; } @@ -34,8 +41,8 @@ export function genMnemonic(): string { export async function signCode( code: string, - privateKey: forge.pki.ed25519.NativeBuffer -): Promise { + privateKey: Uint8Array +): Promise { return forge.pki.ed25519.sign({ encoding: "utf8", message: code, @@ -43,18 +50,85 @@ export async function signCode( }); } +export function bytesToBase64(bytes: Uint8Array) { + return forge.util.encode64(String.fromCodePoint(...bytes)); +} + export function bytesToBase64Url(bytes: Uint8Array): string { - return btoa(String.fromCodePoint(...bytes)) + return bytesToBase64(bytes) .replace(/\//g, "_") .replace(/\+/g, "-") .replace(/=+$/, ""); } -export async function signChallenge(mnemonic: string, challengeCode: string) { - const keys = await keysFromMnemonic(mnemonic); +export async function signChallenge(keys: Keys, challengeCode: string) { const signature = await signCode(challengeCode, keys.privateKey); - return { - publicKey: bytesToBase64Url(keys.publicKey), - signature: bytesToBase64Url(signature), - }; + return bytesToBase64Url(signature); +} + +export function base64ToBuffer(data: string) { + return forge.util.binary.base64.decode(data); +} + +export function base64ToStringBugger(data: string) { + return forge.util.createBuffer(base64ToBuffer(data)); +} + +export function stringBufferToBase64(buffer: forge.util.ByteStringBuffer) { + return forge.util.encode64(buffer.getBytes()); +} + +export async function encryptData(data: string, secret: Uint8Array) { + if (secret.byteLength !== 32) + throw new Error("Secret must be at least 256-bit"); + + const iv = await new Promise((resolve, reject) => { + forge.random.getBytes(16, (err, bytes) => { + if (err) reject(err); + resolve(bytes); + }); + }); + + const cipher = forge.cipher.createCipher( + "AES-GCM", + forge.util.createBuffer(secret) + ); + cipher.start({ + iv, + tagLength: 128, + }); + cipher.update(forge.util.createBuffer(data)); + cipher.finish(); + + const encryptedData = cipher.output; + const tag = cipher.mode.tag; + + return `${forge.util.encode64(iv)}.${stringBufferToBase64( + encryptedData + )}.${stringBufferToBase64(tag)}` as const; +} + +export async function decryptData( + data: `${string}.${string}.${string}`, + secret: Uint8Array +) { + if (secret.byteLength !== 32) throw new Error("Secret must be 256-bit"); + + const [iv, encryptedData, tag] = data.split("."); + + const decipher = forge.cipher.createDecipher( + "AES-GCM", + forge.util.createBuffer(secret) + ); + decipher.start({ + iv: base64ToStringBugger(iv), + tag: base64ToStringBugger(tag), + tagLength: 128, + }); + decipher.update(base64ToStringBugger(encryptedData)); + const pass = decipher.finish(); + + if (!pass) throw new Error("Error decrypting data"); + + return decipher.output.toString(); } diff --git a/src/hooks/auth/useAuth.ts b/src/hooks/auth/useAuth.ts index 8178b355..2a7e6b2d 100644 --- a/src/hooks/auth/useAuth.ts +++ b/src/hooks/auth/useAuth.ts @@ -2,7 +2,9 @@ import { useCallback } from "react"; import { removeSession } from "@/backend/accounts/auth"; import { + bytesToBase64, bytesToBase64Url, + encryptData, keysFromMnemonic, signChallenge, } from "@/backend/accounts/crypto"; @@ -49,22 +51,24 @@ export function useAuth() { const login = useCallback( async (loginData: LoginData) => { const keys = await keysFromMnemonic(loginData.mnemonic); + const publicKeyBase64Url = bytesToBase64Url(keys.publicKey); const { challenge } = await getLoginChallengeToken( backendUrl, - bytesToBase64Url(keys.publicKey) + publicKeyBase64Url ); - const signResult = await signChallenge(loginData.mnemonic, challenge); + const signature = await signChallenge(keys, challenge); const loginResult = await loginAccount(backendUrl, { challenge: { code: challenge, - signature: signResult.signature, + signature, }, - publicKey: signResult.publicKey, - device: loginData.userData.device, + publicKey: publicKeyBase64Url, + device: await encryptData(loginData.userData.device, keys.seed), }); const user = await getUser(backendUrl, loginResult.token); - await userDataLogin(loginResult, user); + const seedBase64 = bytesToBase64(keys.seed); + await userDataLogin(loginResult, user, seedBase64); }, [userDataLogin, backendUrl] ); @@ -86,18 +90,23 @@ export function useAuth() { const register = useCallback( async (registerData: RegistrationData) => { const { challenge } = await getRegisterChallengeToken(backendUrl); - const signResult = await signChallenge(registerData.mnemonic, challenge); + const keys = await keysFromMnemonic(registerData.mnemonic); + const signature = await signChallenge(keys, challenge); const registerResult = await registerAccount(backendUrl, { challenge: { code: challenge, - signature: signResult.signature, + signature, }, - publicKey: signResult.publicKey, - device: registerData.userData.device, + publicKey: bytesToBase64Url(keys.publicKey), + device: await encryptData(registerData.userData.device, keys.seed), profile: registerData.userData.profile, }); - await userDataLogin(registerResult, registerResult.user); + await userDataLogin( + registerResult, + registerResult.user, + bytesToBase64(keys.seed) + ); }, [backendUrl, userDataLogin] ); diff --git a/src/hooks/auth/useAuthData.ts b/src/hooks/auth/useAuthData.ts index 32a58424..e07e8408 100644 --- a/src/hooks/auth/useAuthData.ts +++ b/src/hooks/auth/useAuthData.ts @@ -23,12 +23,13 @@ export function useAuthData() { const replaceItems = useProgressStore((s) => s.replaceItems); const login = useCallback( - async (account: LoginResponse, user: UserResponse) => { + async (account: LoginResponse, user: UserResponse, seed: string) => { setAccount({ token: account.token, userId: user.id, sessionId: account.session.id, profile: user.profile, + seed, }); }, [setAccount] diff --git a/src/stores/auth/index.ts b/src/stores/auth/index.ts index 9e48d046..37d70287 100644 --- a/src/stores/auth/index.ts +++ b/src/stores/auth/index.ts @@ -14,6 +14,7 @@ export type AccountWithToken = Account & { sessionId: string; userId: string; token: string; + seed: string; }; interface AuthStore {