feat: [wip] accountStore

This commit is contained in:
Shibo Lyu 2024-10-14 02:21:29 +08:00
parent 71a7a3c76e
commit 333b5a4ed4
4 changed files with 184 additions and 64 deletions

View file

@ -11,13 +11,13 @@ type SavedObject = {
actKeyPrivate: CryptoKey; actKeyPrivate: CryptoKey;
}; };
type AccountCredentials = { export type AccountCredentials = {
idKeyId: string; idKeyId: string;
encodedIdKeyPair?: EncodedBlahKeyPair; encodedIdKeyPair?: EncodedBlahKeyPair;
actKeyPair: BlahKeyPair; actKeyPair: BlahKeyPair;
}; };
interface AccountKeyStoreDB extends DBSchema { interface AccountKeyDBSchema extends DBSchema {
[IDB_OBJECT_STORE_NAME]: { [IDB_OBJECT_STORE_NAME]: {
key: string; key: string;
value: SavedObject; value: SavedObject;
@ -36,15 +36,15 @@ async function savedObjectToAccountCredentials(
}; };
} }
export class AccountKeyStore { class AccountKeyDB {
private db: IDBPDatabase<AccountKeyStoreDB>; private db: IDBPDatabase<AccountKeyDBSchema>;
private constructor(db: IDBPDatabase<AccountKeyStoreDB>) { private constructor(db: IDBPDatabase<AccountKeyDBSchema>) {
this.db = db; this.db = db;
} }
static async open(): Promise<AccountKeyStore> { static async open(): Promise<AccountKeyDB> {
const db = await openDB<AccountKeyStoreDB>(IDB_NAME, 1, { const db = await openDB<AccountKeyDBSchema>(IDB_NAME, 1, {
upgrade(db) { upgrade(db) {
if (!db.objectStoreNames.contains(IDB_OBJECT_STORE_NAME)) { if (!db.objectStoreNames.contains(IDB_OBJECT_STORE_NAME)) {
const objStore = db.createObjectStore(IDB_OBJECT_STORE_NAME, { keyPath: 'idKeyId' }); 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( async addAccount(
@ -115,3 +115,13 @@ export class AccountKeyStore {
this.db.close(); this.db.close();
} }
} }
let accountKeyDB: AccountKeyDB | null = null;
export async function openAccountKeyDB(): Promise<AccountKeyDB> {
if (!accountKeyDB) {
accountKeyDB = await AccountKeyDB.open();
}
return accountKeyDB;
}
export type { AccountKeyDB };

View file

@ -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<Account[]> {
private keyDB: AccountKeyDB;
private identityFileDB: IdentityFileDB;
private internalStore = writable<Account[]>([]);
subscribe = this.internalStore.subscribe;
private constructor(keyDB: AccountKeyDB, identityFileDB: IdentityFileDB) {
this.keyDB = keyDB;
this.identityFileDB = identityFileDB;
}
static async open(): Promise<AccountStore> {
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<BlahIdentity> {
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);
}
}

View file

@ -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<IdentityFileDBSchema>;
private constructor(db: IDBPDatabase<IdentityFileDBSchema>) {
this.db = db;
}
static async open(): Promise<IdentityFileDB> {
const db = await openDB<IdentityFileDBSchema>(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<void> {
await this.db.put(IDB_OBJECT_STORE_NAME, { ...identityFile, lastUpdatedAt: new Date() });
}
async fetchIdentityFile(idKeyId: string): Promise<BlahIdentityFile | undefined> {
return await this.db.get(IDB_OBJECT_STORE_NAME, idKeyId);
}
async fetchIdentityFiles(idKeyIds: string[]): Promise<Map<string, BlahIdentityFile>> {
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<BlahIdentityFile | undefined> {
return await this.db.getFromIndex(IDB_OBJECT_STORE_NAME, 'id_urls', idUrl);
}
async removeExpiredIdentityFiles(): Promise<void> {
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<IdentityFileDB> {
if (!identityFileDB) {
identityFileDB = await IdentityFileDB.open();
}
return identityFileDB;
}
export type { IdentityFileDB };

View file

@ -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<ProfileStoreDB>;
private constructor(db: IDBPDatabase<ProfileStoreDB>) {
this.db = db;
}
static async open(): Promise<ProfileStore> {
const db = await openDB<ProfileStoreDB>(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<void> {
await this.db.put(IDB_OBJECT_STORE_NAME, { idKeyId, ...profile, lastUpdatedAt: new Date() });
}
async getProfile(idKeyId: string): Promise<BlahProfile | undefined> {
return await this.db.get(IDB_OBJECT_STORE_NAME, idKeyId);
}
async getProfileByIdUrl(idUrl: string): Promise<BlahProfile | undefined> {
return await this.db.getFromIndex(IDB_OBJECT_STORE_NAME, 'id_urls', idUrl);
}
async removeOldProfiles(): Promise<void> {
const now = new Date();
const cutoff = new Date(now.getTime() - PROFILE_MAX_AGE);
await this.db.delete(IDB_OBJECT_STORE_NAME, IDBKeyRange.upperBound(cutoff));
}
}