From 333b5a4ed46a5429077a2adfb1dc715a85c3f1c1 Mon Sep 17 00:00:00 2001 From: Shibo Lyu Date: Mon, 14 Oct 2024 02:21:29 +0800 Subject: [PATCH] feat: [wip] accountStore --- .../accountKeyDB.ts} | 26 ++++-- src/lib/accounts/accountStore.ts | 85 +++++++++++++++++++ src/lib/identityFiles/identityFileDB.ts | 81 ++++++++++++++++++ src/lib/profileStore.ts | 56 ------------ 4 files changed, 184 insertions(+), 64 deletions(-) rename src/lib/{accountKeyStore.ts => accounts/accountKeyDB.ts} (82%) create mode 100644 src/lib/accounts/accountStore.ts create mode 100644 src/lib/identityFiles/identityFileDB.ts delete mode 100644 src/lib/profileStore.ts diff --git a/src/lib/accountKeyStore.ts b/src/lib/accounts/accountKeyDB.ts similarity index 82% rename from src/lib/accountKeyStore.ts rename to src/lib/accounts/accountKeyDB.ts index ebe7dd6..eddb6a7 100644 --- a/src/lib/accountKeyStore.ts +++ b/src/lib/accounts/accountKeyDB.ts @@ -11,13 +11,13 @@ type SavedObject = { actKeyPrivate: CryptoKey; }; -type AccountCredentials = { +export type AccountCredentials = { idKeyId: string; encodedIdKeyPair?: EncodedBlahKeyPair; actKeyPair: BlahKeyPair; }; -interface AccountKeyStoreDB extends DBSchema { +interface AccountKeyDBSchema extends DBSchema { [IDB_OBJECT_STORE_NAME]: { key: string; value: SavedObject; @@ -36,15 +36,15 @@ async function savedObjectToAccountCredentials( }; } -export class AccountKeyStore { - private db: IDBPDatabase; +class AccountKeyDB { + private db: IDBPDatabase; - private constructor(db: IDBPDatabase) { + private constructor(db: IDBPDatabase) { this.db = db; } - static async open(): Promise { - const db = await openDB(IDB_NAME, 1, { + static async open(): Promise { + const db = await openDB(IDB_NAME, 1, { upgrade(db) { if (!db.objectStoreNames.contains(IDB_OBJECT_STORE_NAME)) { const objStore = db.createObjectStore(IDB_OBJECT_STORE_NAME, { keyPath: 'idKeyId' }); @@ -53,7 +53,7 @@ export class AccountKeyStore { } }); - return new AccountKeyStore(db); + return new AccountKeyDB(db); } async addAccount( @@ -115,3 +115,13 @@ export class AccountKeyStore { this.db.close(); } } + +let accountKeyDB: AccountKeyDB | null = null; +export async function openAccountKeyDB(): Promise { + if (!accountKeyDB) { + accountKeyDB = await AccountKeyDB.open(); + } + + return accountKeyDB; +} +export type { AccountKeyDB }; diff --git a/src/lib/accounts/accountStore.ts b/src/lib/accounts/accountStore.ts new file mode 100644 index 0000000..0358d63 --- /dev/null +++ b/src/lib/accounts/accountStore.ts @@ -0,0 +1,85 @@ +import { writable, type Readable } from 'svelte/store'; +import { type AccountKeyDB, openAccountKeyDB } from './accountKeyDB'; +import { BlahIdentity, type BlahIdentityFile, type BlahProfile } from '@blah-im/core/identity'; +import { type IdentityFileDB, openIdentityFileDB } from '$lib/identityFiles/identityFileDB'; +import { BlahKeyPair } from '@blah-im/core/crypto'; + +export type Account = BlahIdentityFile & { + holdingKeyPrivate: boolean; + holdingPrivateOfActKey?: string; +}; + +export class AccountStore implements Readable { + private keyDB: AccountKeyDB; + private identityFileDB: IdentityFileDB; + private internalStore = writable([]); + subscribe = this.internalStore.subscribe; + + private constructor(keyDB: AccountKeyDB, identityFileDB: IdentityFileDB) { + this.keyDB = keyDB; + this.identityFileDB = identityFileDB; + } + + static async open(): Promise { + const keyDB = await openAccountKeyDB(); + const identityFileDB = await openIdentityFileDB(); + const store = new AccountStore(keyDB, identityFileDB); + await store.loadAccounts(); + return store; + } + + async loadAccounts() { + const accountCreds = await this.keyDB.fetchAllAccounts(); + const identityFileMap = await this.identityFileDB.fetchIdentityFiles( + accountCreds.map((x) => x.idKeyId) + ); + + const accounts = accountCreds.flatMap((creds) => { + const identityFile = identityFileMap.get(creds.idKeyId); + if (!identityFile) return []; + return [ + { + ...identityFile, + holdingKeyPrivate: !!creds.encodedIdKeyPair, + holdingPrivateOfActKey: creds.actKeyPair.id + } + ]; + }); + + this.internalStore.set(accounts); + } + + async identityForAccount( + accountOrIdKeyId: Account | string, + password?: string + ): Promise { + const idKeyId = + typeof accountOrIdKeyId === 'string' ? accountOrIdKeyId : accountOrIdKeyId.id_key; + + const identityFile = await this.identityFileDB.fetchIdentityFile(idKeyId); + if (!identityFile) throw new Error('Identity file not found'); + + const accountCreds = await this.keyDB.fetchAccount(idKeyId); + const encodedIdKeyPair = accountCreds?.encodedIdKeyPair; + const idKeyPair = encodedIdKeyPair + ? await BlahKeyPair.fromEncoded(encodedIdKeyPair, password) + : undefined; + const actKeyPair = accountCreds?.actKeyPair; + + return await BlahIdentity.fromIdentityFile(identityFile, idKeyPair, actKeyPair); + } + + async saveIdentityFile(identity: BlahIdentity) { + const identityFile = identity.generateIdentityFile(); + await this.identityFileDB.updateIdentityFile(identityFile); + } + + async createAccount(profile: BlahProfile, password: string) { + const idKeyPair = await BlahKeyPair.generate(true); + const actKeyPair = await BlahKeyPair.generate(false); + const identity = await BlahIdentity.create(idKeyPair, actKeyPair, profile); + const encodedIdKeyPair = await idKeyPair.encode(password); + await this.keyDB.addAccount(idKeyPair.id, actKeyPair, encodedIdKeyPair); + await this.saveIdentityFile(identity); + } +} diff --git a/src/lib/identityFiles/identityFileDB.ts b/src/lib/identityFiles/identityFileDB.ts new file mode 100644 index 0000000..32a675d --- /dev/null +++ b/src/lib/identityFiles/identityFileDB.ts @@ -0,0 +1,81 @@ +import type { BlahIdentityFile } from '@blah-im/core/identity'; +import { openDB, type DBSchema, type IDBPDatabase } from 'idb'; + +const IDB_NAME = 'weblah-identities'; +const IDB_OBJECT_STORE_NAME = 'identities'; + +const IDENTITY_FILE_MAX_AGE = 1000 * 60 * 60 * 24 * 30; // 30 days + +interface IdentityFileDBSchema extends DBSchema { + [IDB_OBJECT_STORE_NAME]: { + key: string; + value: BlahIdentityFile & { lastUpdatedAt: Date }; + indexes: { id_urls: string }; + }; +} + +class IdentityFileDB { + private db: IDBPDatabase; + + private constructor(db: IDBPDatabase) { + this.db = db; + } + + static async open(): Promise { + const db = await openDB(IDB_NAME, 1, { + upgrade(db) { + if (!db.objectStoreNames.contains(IDB_OBJECT_STORE_NAME)) { + const store = db.createObjectStore(IDB_OBJECT_STORE_NAME, { keyPath: 'idKeyId' }); + store.createIndex('id_urls', 'profile.signee.payload.id_urls', { + multiEntry: true, + unique: true + }); + } + } + }); + + const store = new IdentityFileDB(db); + await store.removeExpiredIdentityFiles(); + return store; + } + + async updateIdentityFile(identityFile: BlahIdentityFile): Promise { + await this.db.put(IDB_OBJECT_STORE_NAME, { ...identityFile, lastUpdatedAt: new Date() }); + } + + async fetchIdentityFile(idKeyId: string): Promise { + return await this.db.get(IDB_OBJECT_STORE_NAME, idKeyId); + } + + async fetchIdentityFiles(idKeyIds: string[]): Promise> { + return new Map( + ( + await Promise.all( + idKeyIds.map(async (idKeyId): Promise<[string, BlahIdentityFile] | null> => { + const profile = await this.fetchIdentityFile(idKeyId); + return profile ? [idKeyId, profile] : null; + }) + ) + ).filter((x): x is [string, BlahIdentityFile] => !!x) + ); + } + + async getIdentityFileByIdUrl(idUrl: string): Promise { + return await this.db.getFromIndex(IDB_OBJECT_STORE_NAME, 'id_urls', idUrl); + } + + async removeExpiredIdentityFiles(): Promise { + const now = new Date(); + const cutoff = new Date(now.getTime() - IDENTITY_FILE_MAX_AGE); + await this.db.delete(IDB_OBJECT_STORE_NAME, IDBKeyRange.upperBound(cutoff)); + } +} + +let identityFileDB: IdentityFileDB | null = null; +export async function openIdentityFileDB(): Promise { + if (!identityFileDB) { + identityFileDB = await IdentityFileDB.open(); + } + return identityFileDB; +} +export type { IdentityFileDB }; diff --git a/src/lib/profileStore.ts b/src/lib/profileStore.ts deleted file mode 100644 index f3e6b56..0000000 --- a/src/lib/profileStore.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { BlahProfile } from '@blah-im/core/identity'; -import { openDB, type DBSchema, type IDBPDatabase } from 'idb'; - -const IDB_NAME = 'weblah-profiles'; -const IDB_OBJECT_STORE_NAME = 'profiles'; - -const PROFILE_MAX_AGE = 1000 * 60 * 60 * 24 * 30; // 30 days - -interface ProfileStoreDB extends DBSchema { - [IDB_OBJECT_STORE_NAME]: { - key: string; - value: BlahProfile & { idKeyId: string; lastUpdatedAt: Date }; - indexes: { id_urls: string }; - }; -} - -export class ProfileStore { - private db: IDBPDatabase; - - private constructor(db: IDBPDatabase) { - this.db = db; - } - - static async open(): Promise { - const db = await openDB(IDB_NAME, 1, { - upgrade(db) { - if (!db.objectStoreNames.contains(IDB_OBJECT_STORE_NAME)) { - const store = db.createObjectStore(IDB_OBJECT_STORE_NAME, { keyPath: 'idKeyId' }); - store.createIndex('id_urls', 'id_urls', { multiEntry: true, unique: true }); - } - } - }); - - const store = new ProfileStore(db); - await store.removeOldProfiles(); - return store; - } - - async updateProfile(idKeyId: string, profile: BlahProfile): Promise { - await this.db.put(IDB_OBJECT_STORE_NAME, { idKeyId, ...profile, lastUpdatedAt: new Date() }); - } - - async getProfile(idKeyId: string): Promise { - return await this.db.get(IDB_OBJECT_STORE_NAME, idKeyId); - } - - async getProfileByIdUrl(idUrl: string): Promise { - return await this.db.getFromIndex(IDB_OBJECT_STORE_NAME, 'id_urls', idUrl); - } - - async removeOldProfiles(): Promise { - const now = new Date(); - const cutoff = new Date(now.getTime() - PROFILE_MAX_AGE); - await this.db.delete(IDB_OBJECT_STORE_NAME, IDBKeyRange.upperBound(cutoff)); - } -}