Files
hspguard/web/src/repository/account.ts
2025-05-24 17:46:06 +02:00

141 lines
3.5 KiB
TypeScript

import { useDbContext } from "@/context/db/db";
import { deriveDeviceKey, getDeviceId } from "@/util/deviceId";
import { useCallback } from "react";
export interface RawLocalAccount {
accountId: string;
label: string;
email: string;
profilePicture: string;
access: { data: number[]; iv: number[] };
refresh: { data: number[]; iv: number[] };
updatedAt: string;
}
export interface LocalAccount {
accountId: string;
label: string;
email: string;
profilePicture: string;
access: string;
refresh: string;
updatedAt: string;
}
export interface CreateAccountRequest {
accountId: string;
label: string;
email: string;
profilePicture: string;
access: string;
refresh: string;
}
export const useAccountRepo = () => {
const { db } = useDbContext();
const getDeviceKey = useCallback(async () => {
const deviceId = await getDeviceId();
const deviceKey = await deriveDeviceKey(deviceId);
return deviceKey;
}, []);
const encryptToken = useCallback(
async (token: string) => {
const encoder = new TextEncoder();
const iv = crypto.getRandomValues(new Uint8Array(12));
const deviceKey = await getDeviceKey();
const cipherText = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
deviceKey,
encoder.encode(token)
);
return {
data: Array.from(new Uint8Array(cipherText)),
iv: Array.from(iv),
};
},
[getDeviceKey]
);
const decryptToken = useCallback(
async (encrypted: { data: number[]; iv: number[] }) => {
const decoder = new TextDecoder();
const deviceKey = await getDeviceKey();
const decrypted = await crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: new Uint8Array(encrypted.iv),
},
deviceKey,
new Uint8Array(encrypted.data)
);
return decoder.decode(decrypted);
},
[getDeviceKey]
);
const save = useCallback(
async (req: CreateAccountRequest) => {
if (!db) throw new Error("No database connection");
const access = await encryptToken(req.access);
const refresh = await encryptToken(req.refresh);
await db?.put?.("accounts", {
accountId: req.accountId,
label: req.label,
email: req.email,
profilePicture: req.profilePicture,
access,
refresh,
updatedAt: new Date().toISOString(),
});
},
[db, encryptToken]
);
const loadAll = useCallback(async () => {
if (!db) throw new Error("No database connection");
const tx = db.transaction("accounts", "readonly");
const store = tx.objectStore("accounts");
const accounts: RawLocalAccount[] = await store.getAll();
const results: LocalAccount[] = (
await Promise.all(
accounts.map(async (account) => {
try {
const accessToken = await decryptToken(account.access);
const refreshToken = await decryptToken(account.refresh);
return {
accountId: account.accountId,
label: account.label,
email: account.email,
profilePicture: account.profilePicture,
access: accessToken,
refresh: refreshToken,
updatedAt: account.updatedAt,
};
} catch (err) {
console.warn(`Failed to decrypt account ${account.label}:`, err);
}
})
)
).filter((acc) => acc !== undefined);
return results;
}, [db, decryptToken]);
return { save, loadAll };
};