first chrome implementation

This commit is contained in:
DEV Sam Hayes
2025-01-10 19:37:10 +01:00
parent dc7a980dc5
commit a652718bc7
175 changed files with 18526 additions and 610 deletions

View File

@@ -0,0 +1,3 @@
export abstract class BrowserLocalHandler {
}

View File

@@ -0,0 +1,31 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { BrowserSessionData } from './types';
export abstract class BrowserSessionHandler {
get browserSessionData(): BrowserSessionData | undefined {
return this.#browserSessionData;
}
#browserSessionData?: BrowserSessionData;
/**
* Load the data from the browser session storage. It should be an empty object,
* if no data is available yet (e.g. because the vault (from the browser sync data)
* was not unlocked via password).
*
* ATTENTION: Make sure to call "setFullData(..)" afterwards to update the in-memory data.
*/
abstract loadFullData(): Promise<Partial<Record<string, any>>>;
setFullData(data: BrowserSessionData) {
this.#browserSessionData = JSON.parse(JSON.stringify(data));
}
/**
* Persist the full data to the session data storage.
*
* ATTENTION: Make sure to call "setFullData(..)" afterwards of before to update the in-memory data.
*/
abstract saveFullData(data: BrowserSessionData): Promise<void>;
abstract clearData(): Promise<void>;
}

View File

@@ -0,0 +1,111 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
BrowserSyncData,
Identity_ENCRYPTED,
Permission_ENCRYPTED,
Relay_ENCRYPTED,
} from './types';
/**
* This class handles the data that is synced between browser instances.
* In addition to the sensitive data that is encrypted, it also contains
* some unencrypted properties (like, version and the vault hash).
*/
export abstract class BrowserSyncHandler {
get browserSyncData(): BrowserSyncData | undefined {
return this.#browserSyncData;
}
get ignoreProperties(): string[] {
return this.#ignoreProperties;
}
#browserSyncData?: BrowserSyncData;
#ignoreProperties: string[] = [];
setIgnoreProperties(properties: string[]) {
this.#ignoreProperties = properties;
}
/**
* Load data from the sync data storage. This data might be
* outdated (i.e. it is unmigrated), so check the unencrypted property "version" after loading.
* Also make sure to handle the "ignore properties" (if available).
*/
abstract loadUnmigratedData(): Promise<Partial<Record<string, any>>>;
/**
* Persist the full data to the sync data storage.
*
* ATTENTION: In your implementation, make sure to call "setFullData(..)" at the end to update the in-memory data.
*/
abstract saveAndSetFullData(data: BrowserSyncData): Promise<void>;
setFullData(data: BrowserSyncData) {
this.#browserSyncData = JSON.parse(JSON.stringify(data));
}
/**
* Persist the permissions to the sync data storage.
*
* ATTENTION: In your implementation, make sure to call "setPartialData_Permissions(..)" at the end to update the in-memory data.
*/
abstract saveAndSetPartialData_Permissions(data: {
permissions: Permission_ENCRYPTED[];
}): Promise<void>;
setPartialData_Permissions(data: { permissions: Permission_ENCRYPTED[] }) {
if (!this.#browserSyncData) {
return;
}
this.#browserSyncData.permissions = Array.from(data.permissions);
}
/**
* Persist the identities to the sync data storage.
*
* ATTENTION: In your implementation, make sure to call "setPartialData_Identities(..)" at the end to update the in-memory data.
*/
abstract saveAndSetPartialData_Identities(data: {
identities: Identity_ENCRYPTED[];
}): Promise<void>;
setPartialData_Identities(data: { identities: Identity_ENCRYPTED[] }) {
if (!this.#browserSyncData) {
return;
}
this.#browserSyncData.identities = Array.from(data.identities);
}
/**
* Persist the selected identity id to the sync data storage.
*
* ATTENTION: In your implementation, make sure to call "setPartialData_SelectedIdentityId(..)" at the end to update the in-memory data.
*/
abstract saveAndSetPartialData_SelectedIdentityId(data: {
selectedIdentityId: string | null;
}): Promise<void>;
setPartialData_SelectedIdentityId(data: {
selectedIdentityId: string | null;
}) {
if (!this.#browserSyncData) {
return;
}
this.#browserSyncData.selectedIdentityId = data.selectedIdentityId;
}
abstract saveAndSetPartialData_Relays(data: {
relays: Relay_ENCRYPTED[];
}): Promise<void>;
setPartialData_Relays(data: { relays: Relay_ENCRYPTED[] }) {
if (!this.#browserSyncData) {
return;
}
this.#browserSyncData.relays = Array.from(data.relays);
}
/**
* Clear all data from the sync data storage.
*/
abstract clearData(): Promise<void>;
}

View File

@@ -0,0 +1,43 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { BrowserSyncFlow, GootiMetaData } from './types';
export abstract class GootiMetaHandler {
get gootiMetaData(): GootiMetaData | undefined {
return this.#gootiMetaData;
}
#gootiMetaData?: GootiMetaData;
readonly metaProperties = ['syncFlow'];
/**
* Load the full data from the storage. If the storage is used for storing
* other data (e.g. browser sync data when the user decided to NOT sync),
* make sure to handle the "meta properties" to only load these.
*
* ATTENTION: Make sure to call "setFullData(..)" afterwards to update the in-memory data.
*/
abstract loadFullData(): Promise<Partial<Record<string, any>>>;
setFullData(data: GootiMetaData) {
this.#gootiMetaData = data;
}
abstract saveFullData(data: GootiMetaData): Promise<void>;
/**
* Sets the browser sync flow for the user and immediately saves it.
*/
async setBrowserSyncFlow(flow: BrowserSyncFlow): Promise<void> {
if (!this.#gootiMetaData) {
this.#gootiMetaData = {
syncFlow: flow,
};
} else {
this.#gootiMetaData.syncFlow = flow;
}
await this.saveFullData(this.#gootiMetaData);
}
abstract clearData(): Promise<void>;
}

View File

@@ -0,0 +1,232 @@
import {
CryptoHelper,
Identity_DECRYPTED,
Identity_ENCRYPTED,
NostrHelper,
StorageService,
} from '@common';
export const addIdentity = async function (
this: StorageService,
data: {
nick: string;
privkeyString: string;
}
): Promise<void> {
this.assureIsInitialized();
const privkey = NostrHelper.getNostrPrivkeyObject(
data.privkeyString.toLowerCase()
).hex;
// Check if an identity with the same privkey already exists.
const existingIdentity = (
this.getBrowserSessionHandler().browserSessionData?.identities ?? []
).find((x) => x.privkey === privkey);
if (existingIdentity) {
throw new Error(
`An identity with the same private key already exists: ${existingIdentity.nick}`
);
}
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
if (!browserSessionData) {
throw new Error('Browser session data is undefined.');
}
const decryptedIdentity: Identity_DECRYPTED = {
id: CryptoHelper.v4(),
nick: data.nick,
privkey,
createdAt: new Date().toISOString(),
};
// Add the new identity to the session data.
browserSessionData.identities.push(decryptedIdentity);
let isFirstIdentity = false;
if (browserSessionData.identities.length === 1) {
isFirstIdentity = true;
browserSessionData.selectedIdentityId = decryptedIdentity.id;
}
this.getBrowserSessionHandler().saveFullData(browserSessionData);
// Encrypt the new identity and add it to the sync data.
const encryptedIdentity = await encryptIdentity.call(this, decryptedIdentity);
const encryptedIdentities = [
...(this.getBrowserSyncHandler().browserSyncData?.identities ?? []),
encryptedIdentity,
];
await this.getBrowserSyncHandler().saveAndSetPartialData_Identities({
identities: encryptedIdentities,
});
if (isFirstIdentity) {
await this.getBrowserSyncHandler().saveAndSetPartialData_SelectedIdentityId(
{
selectedIdentityId: encryptedIdentity.id,
}
);
}
};
export const deleteIdentity = async function (
this: StorageService,
identityId: string | undefined
): Promise<void> {
this.assureIsInitialized();
if (!identityId) {
return;
}
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
const browserSyncData = this.getBrowserSyncHandler().browserSyncData;
if (!browserSessionData || !browserSyncData) {
throw new Error('Browser session or sync data is undefined.');
}
browserSessionData.identities = browserSessionData.identities.filter(
(x) => x.id !== identityId
);
browserSessionData.permissions = browserSessionData.permissions.filter(
(x) => x.identityId !== identityId
);
browserSessionData.relays = browserSessionData.relays.filter(
(x) => x.identityId !== identityId
);
if (browserSessionData.selectedIdentityId === identityId) {
// Choose another identity to be selected or null if there is none.
browserSessionData.selectedIdentityId =
browserSessionData.identities.length > 0
? browserSessionData.identities[0].id
: null;
}
await this.getBrowserSessionHandler().saveFullData(browserSessionData);
// Handle Sync data.
const encryptedIdentityId = await this.encrypt(identityId);
await this.getBrowserSyncHandler().saveAndSetPartialData_Identities({
identities: browserSyncData.identities.filter(
(x) => x.id !== encryptedIdentityId
),
});
await this.getBrowserSyncHandler().saveAndSetPartialData_Permissions({
permissions: browserSyncData.permissions.filter(
(x) => x.identityId !== encryptedIdentityId
),
});
await this.getBrowserSyncHandler().saveAndSetPartialData_Relays({
relays: browserSyncData.relays.filter(
(x) => x.identityId !== encryptedIdentityId
),
});
await this.getBrowserSyncHandler().saveAndSetPartialData_SelectedIdentityId({
selectedIdentityId:
browserSessionData.selectedIdentityId === null
? null
: await this.encrypt(browserSessionData.selectedIdentityId),
});
};
export const switchIdentity = async function (
this: StorageService,
identityId: string | null
): Promise<void> {
this.assureIsInitialized();
// Check, if the identity really exists.
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
if (!browserSessionData?.identities.find((x) => x.id === identityId)) {
return;
}
browserSessionData.selectedIdentityId = identityId;
await this.getBrowserSessionHandler().saveFullData(browserSessionData);
const encryptedIdentityId =
identityId === null ? null : await this.encrypt(identityId);
await this.getBrowserSyncHandler().saveAndSetPartialData_SelectedIdentityId({
selectedIdentityId: encryptedIdentityId,
});
};
export const encryptIdentity = async function (
this: StorageService,
identity: Identity_DECRYPTED
): Promise<Identity_ENCRYPTED> {
const encryptedIdentity: Identity_ENCRYPTED = {
id: await this.encrypt(identity.id),
nick: await this.encrypt(identity.nick),
createdAt: await this.encrypt(identity.createdAt),
privkey: await this.encrypt(identity.privkey),
};
return encryptedIdentity;
};
export const decryptIdentities = async function (
this: StorageService,
identities: Identity_ENCRYPTED[],
withLockedVault: { iv: string; password: string } | undefined = undefined
): Promise<Identity_DECRYPTED[]> {
const decryptedIdentities: Identity_DECRYPTED[] = [];
for (const identity of identities) {
const decryptedIdentity = await decryptIdentity.call(
this,
identity,
withLockedVault
);
decryptedIdentities.push(decryptedIdentity);
}
return decryptedIdentities;
};
export const decryptIdentity = async function (
this: StorageService,
identity: Identity_ENCRYPTED,
withLockedVault: { iv: string; password: string } | undefined = undefined
): Promise<Identity_DECRYPTED> {
if (typeof withLockedVault === 'undefined') {
const decryptedIdentity: Identity_DECRYPTED = {
id: await this.decrypt(identity.id, 'string'),
nick: await this.decrypt(identity.nick, 'string'),
createdAt: await this.decrypt(identity.createdAt, 'string'),
privkey: await this.decrypt(identity.privkey, 'string'),
};
return decryptedIdentity;
}
const decryptedIdentity: Identity_DECRYPTED = {
id: await this.decryptWithLockedVault(
identity.id,
'string',
withLockedVault.iv,
withLockedVault.password
),
nick: await this.decryptWithLockedVault(
identity.nick,
'string',
withLockedVault.iv,
withLockedVault.password
),
createdAt: await this.decryptWithLockedVault(
identity.createdAt,
'string',
withLockedVault.iv,
withLockedVault.password
),
privkey: await this.decryptWithLockedVault(
identity.privkey,
'string',
withLockedVault.iv,
withLockedVault.password
),
};
return decryptedIdentity;
};

View File

@@ -0,0 +1,111 @@
import {
Permission_DECRYPTED,
Permission_ENCRYPTED,
StorageService,
} from '@common';
export const deletePermission = async function (
this: StorageService,
permissionId: string
): Promise<void> {
this.assureIsInitialized();
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
const browserSyncData = this.getBrowserSyncHandler().browserSyncData;
if (!browserSessionData || !browserSyncData) {
throw new Error('Browser session or sync data is undefined.');
}
browserSessionData.permissions = browserSessionData.permissions.filter(
(x) => x.id !== permissionId
);
await this.getBrowserSessionHandler().saveFullData(browserSessionData);
const encryptedPermissionId = await this.encrypt(permissionId);
await this.getBrowserSyncHandler().saveAndSetPartialData_Permissions({
permissions: browserSyncData.permissions.filter(
(x) => x.id !== encryptedPermissionId
),
});
};
export const decryptPermission = async function (
this: StorageService,
permission: Permission_ENCRYPTED,
withLockedVault: { iv: string; password: string } | undefined = undefined
): Promise<Permission_DECRYPTED> {
if (typeof withLockedVault === 'undefined') {
const decryptedPermission: Permission_DECRYPTED = {
id: await this.decrypt(permission.id, 'string'),
identityId: await this.decrypt(permission.identityId, 'string'),
method: await this.decrypt(permission.method, 'string'),
methodPolicy: await this.decrypt(permission.methodPolicy, 'string'),
host: await this.decrypt(permission.host, 'string'),
};
if (permission.kind) {
decryptedPermission.kind = await this.decrypt(permission.kind, 'number');
}
return decryptedPermission;
}
const decryptedPermission: Permission_DECRYPTED = {
id: await this.decryptWithLockedVault(
permission.id,
'string',
withLockedVault.iv,
withLockedVault.password
),
identityId: await this.decryptWithLockedVault(
permission.identityId,
'string',
withLockedVault.iv,
withLockedVault.password
),
method: await this.decryptWithLockedVault(
permission.method,
'string',
withLockedVault.iv,
withLockedVault.password
),
methodPolicy: await this.decryptWithLockedVault(
permission.methodPolicy,
'string',
withLockedVault.iv,
withLockedVault.password
),
host: await this.decryptWithLockedVault(
permission.host,
'string',
withLockedVault.iv,
withLockedVault.password
),
};
if (permission.kind) {
decryptedPermission.kind = await this.decryptWithLockedVault(
permission.kind,
'number',
withLockedVault.iv,
withLockedVault.password
);
}
return decryptedPermission;
};
export const decryptPermissions = async function (
this: StorageService,
permissions: Permission_ENCRYPTED[],
withLockedVault: { iv: string; password: string } | undefined = undefined
): Promise<Permission_DECRYPTED[]> {
const decryptedPermissions: Permission_DECRYPTED[] = [];
for (const permission of permissions) {
const decryptedPermission = await decryptPermission.call(
this,
permission,
withLockedVault
);
decryptedPermissions.push(decryptedPermission);
}
return decryptedPermissions;
};

View File

@@ -0,0 +1,209 @@
import {
CryptoHelper,
Relay_DECRYPTED,
Relay_ENCRYPTED,
StorageService,
} from '@common';
export const addRelay = async function (
this: StorageService,
data: {
identityId: string;
url: string;
write: boolean;
read: boolean;
}
): Promise<void> {
this.assureIsInitialized();
// Check, if a relay with the same URL already exists for the identity.
const existingRelay =
this.getBrowserSessionHandler().browserSessionData?.relays.find(
(x) =>
x.url.toLowerCase() === data.url.toLowerCase() &&
x.identityId === data.identityId
);
if (existingRelay) {
throw new Error('A relay with the same URL already exists.');
}
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
if (!browserSessionData) {
throw new Error('Browser session data is undefined.');
}
const decryptedRelay: Relay_DECRYPTED = {
id: CryptoHelper.v4(),
identityId: data.identityId,
url: data.url,
write: data.write,
read: data.read,
};
// Add the new relay to the session data.
browserSessionData.relays.push(decryptedRelay);
this.getBrowserSessionHandler().saveFullData(browserSessionData);
// Encrypt the new relay and add it to the sync data.
const encryptedRelay = await encryptRelay.call(this, decryptedRelay);
const encryptedRelays = [
...(this.getBrowserSyncHandler().browserSyncData?.relays ?? []),
encryptedRelay,
];
await this.getBrowserSyncHandler().saveAndSetPartialData_Relays({
relays: encryptedRelays,
});
};
export const deleteRelay = async function (
this: StorageService,
relayId: string
): Promise<void> {
this.assureIsInitialized();
if (!relayId) {
return;
}
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
const browserSyncData = this.getBrowserSyncHandler().browserSyncData;
if (!browserSessionData || !browserSyncData) {
throw new Error('Browser session or sync data is undefined.');
}
browserSessionData.relays = browserSessionData.relays.filter(
(x) => x.id !== relayId
);
await this.getBrowserSessionHandler().saveFullData(browserSessionData);
// Handle Sync data.
const encryptedRelayId = await this.encrypt(relayId);
await this.getBrowserSyncHandler().saveAndSetPartialData_Relays({
relays: browserSyncData.relays.filter((x) => x.id !== encryptedRelayId),
});
};
export const updateRelay = async function (
this: StorageService,
relayClone: Relay_DECRYPTED
): Promise<void> {
this.assureIsInitialized();
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
const browserSyncData = this.getBrowserSyncHandler().browserSyncData;
if (!browserSessionData || !browserSyncData) {
throw new Error('Browser session or sync data is undefined.');
}
const sessionRelay = browserSessionData.relays.find(
(x) => x.id === relayClone.id
);
const encryptedRelayId = await this.encrypt(relayClone.id);
const syncRelay = browserSyncData.relays.find(
(x) => x.id === encryptedRelayId
);
if (!sessionRelay || !syncRelay) {
throw new Error(
'Relay not found in browser session or sync data for update.'
);
}
// Handle Session update.
sessionRelay.read = relayClone.read;
sessionRelay.write = relayClone.write;
sessionRelay.url = relayClone.url;
await this.getBrowserSessionHandler().saveFullData(browserSessionData);
// Handle Sync update.
syncRelay.read = await this.encrypt(relayClone.read.toString());
syncRelay.write = await this.encrypt(relayClone.write.toString());
syncRelay.url = await this.encrypt(relayClone.url);
await this.getBrowserSyncHandler().saveAndSetPartialData_Relays({
relays: browserSyncData.relays,
});
};
export const decryptRelay = async function (
this: StorageService,
relay: Relay_ENCRYPTED,
withLockedVault: { iv: string; password: string } | undefined = undefined
): Promise<Relay_DECRYPTED> {
if (typeof withLockedVault === 'undefined') {
const decryptedRelay: Relay_DECRYPTED = {
id: await this.decrypt(relay.id, 'string'),
identityId: await this.decrypt(relay.identityId, 'string'),
url: await this.decrypt(relay.url, 'string'),
read: await this.decrypt(relay.read, 'boolean'),
write: await this.decrypt(relay.write, 'boolean'),
};
return decryptedRelay;
}
const decryptedRelay: Relay_DECRYPTED = {
id: await this.decryptWithLockedVault(
relay.id,
'string',
withLockedVault.iv,
withLockedVault.password
),
identityId: await this.decryptWithLockedVault(
relay.identityId,
'string',
withLockedVault.iv,
withLockedVault.password
),
url: await this.decryptWithLockedVault(
relay.url,
'string',
withLockedVault.iv,
withLockedVault.password
),
read: await this.decryptWithLockedVault(
relay.read,
'boolean',
withLockedVault.iv,
withLockedVault.password
),
write: await this.decryptWithLockedVault(
relay.write,
'boolean',
withLockedVault.iv,
withLockedVault.password
),
};
return decryptedRelay;
};
export const decryptRelays = async function (
this: StorageService,
relays: Relay_ENCRYPTED[],
withLockedVault: { iv: string; password: string } | undefined = undefined
): Promise<Relay_DECRYPTED[]> {
const decryptedRelays: Relay_DECRYPTED[] = [];
for (const relay of relays) {
const decryptedRelay = await decryptRelay.call(
this,
relay,
withLockedVault
);
decryptedRelays.push(decryptedRelay);
}
return decryptedRelays;
};
export const encryptRelay = async function (
this: StorageService,
relay: Relay_DECRYPTED
): Promise<Relay_ENCRYPTED> {
const encryptedRelay: Relay_ENCRYPTED = {
id: await this.encrypt(relay.id),
identityId: await this.encrypt(relay.identityId),
url: await this.encrypt(relay.url),
read: await this.encrypt(relay.read.toString()),
write: await this.encrypt(relay.write.toString()),
};
return encryptedRelay;
};

View File

@@ -0,0 +1,128 @@
import {
BrowserSessionData,
BrowserSyncData,
CryptoHelper,
StorageService,
} from '@common';
import { decryptIdentities } from './identity';
import { decryptPermissions } from './permission';
import { decryptRelays } from './relay';
export const createNewVault = async function (
this: StorageService,
password: string
): Promise<void> {
this.assureIsInitialized();
const vaultHash = await CryptoHelper.hash(password);
const sessionData: BrowserSessionData = {
iv: CryptoHelper.generateIV(),
vaultPassword: password,
identities: [],
permissions: [],
relays: [],
selectedIdentityId: null,
};
await this.getBrowserSessionHandler().saveFullData(sessionData);
this.getBrowserSessionHandler().setFullData(sessionData);
const syncData: BrowserSyncData = {
version: this.latestVersion,
iv: sessionData.iv,
vaultHash,
identities: [],
permissions: [],
relays: [],
selectedIdentityId: null,
};
await this.getBrowserSyncHandler().saveAndSetFullData(syncData);
};
export const unlockVault = async function (
this: StorageService,
password: string
): Promise<void> {
this.assureIsInitialized();
let browserSessionData = this.getBrowserSessionHandler().browserSessionData;
if (browserSessionData) {
throw new Error(
'Browser session data is available. Should only happen when the vault is unlocked'
);
}
const browserSyncData = this.getBrowserSyncHandler().browserSyncData;
if (!browserSyncData) {
throw new Error(
'Browser sync data is not available. Should have been loaded before.'
);
}
const passwordHash = await CryptoHelper.hash(password);
if (passwordHash !== browserSyncData.vaultHash) {
throw new Error('Invalid password.');
}
// Ok. Everything is fine. We can unlock the vault now.
// Decrypt the identities.
const withLockedVault = {
iv: browserSyncData.iv,
password,
};
const decryptedIdentities = await decryptIdentities.call(
this,
browserSyncData.identities,
withLockedVault
);
const decryptedPermissions = await decryptPermissions.call(
this,
browserSyncData.permissions,
withLockedVault
);
const decryptedRelays = await decryptRelays.call(
this,
browserSyncData.relays,
withLockedVault
);
const decryptedSelectedIdentityId =
browserSyncData.selectedIdentityId === null
? null
: await this.decryptWithLockedVault(
browserSyncData.selectedIdentityId,
'string',
browserSyncData.iv,
password
);
browserSessionData = {
vaultPassword: password,
iv: browserSyncData.iv,
permissions: decryptedPermissions,
identities: decryptedIdentities,
selectedIdentityId: decryptedSelectedIdentityId,
relays: decryptedRelays,
};
await this.getBrowserSessionHandler().saveFullData(browserSessionData);
this.getBrowserSessionHandler().setFullData(browserSessionData);
};
export const deleteVault = async function (
this: StorageService,
doNotSetIsInitializedToFalse: boolean
): Promise<void> {
this.assureIsInitialized();
const syncFlow = this.getGootiMetaHandler().gootiMetaData?.syncFlow;
if (typeof syncFlow === 'undefined') {
throw new Error('Sync flow is not set.');
}
await this.getBrowserSyncHandler().clearData();
await this.getBrowserSessionHandler().clearData();
await this.getGootiMetaHandler().clearData();
if (!doNotSetIsInitializedToFalse) {
this.isInitialized = false;
}
};

View File

@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { StorageService } from './storage.service';
describe('StorageService', () => {
let service: StorageService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(StorageService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,335 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Injectable } from '@angular/core';
import { BrowserSyncHandler } from './browser-sync-handler';
import { BrowserSessionHandler } from './browser-session-handler';
import {
BrowserSessionData,
BrowserSyncData,
BrowserSyncFlow,
GootiMetaData,
Relay_DECRYPTED,
} from './types';
import { GootiMetaHandler } from './gooti-meta-handler';
import { CryptoHelper } from '@common';
import {
addIdentity,
deleteIdentity,
switchIdentity,
} from './related/identity';
import { deletePermission } from './related/permission';
import { createNewVault, deleteVault, unlockVault } from './related/vault';
import { addRelay, deleteRelay, updateRelay } from './related/relay';
export interface StorageServiceConfig {
browserSessionHandler: BrowserSessionHandler;
browserSyncYesHandler: BrowserSyncHandler;
browserSyncNoHandler: BrowserSyncHandler;
gootiMetaHandler: GootiMetaHandler;
}
@Injectable({
providedIn: 'root',
})
export class StorageService {
readonly latestVersion = 1;
isInitialized = false;
#browserSessionHandler!: BrowserSessionHandler;
#browserSyncYesHandler!: BrowserSyncHandler;
#browserSyncNoHandler!: BrowserSyncHandler;
#gootiMetaHandler!: GootiMetaHandler;
initialize(config: StorageServiceConfig): void {
if (this.isInitialized) {
return;
}
this.#browserSessionHandler = config.browserSessionHandler;
this.#browserSyncYesHandler = config.browserSyncYesHandler;
this.#browserSyncNoHandler = config.browserSyncNoHandler;
this.#gootiMetaHandler = config.gootiMetaHandler;
this.isInitialized = true;
}
async enableBrowserSyncFlow(flow: BrowserSyncFlow): Promise<void> {
this.assureIsInitialized();
this.#gootiMetaHandler.setBrowserSyncFlow(flow);
}
async loadGootiMetaData(): Promise<GootiMetaData | undefined> {
this.assureIsInitialized();
const data = await this.#gootiMetaHandler.loadFullData();
if (Object.keys(data).length === 0) {
// No data available yet.
return undefined;
}
this.#gootiMetaHandler.setFullData(data as GootiMetaData);
return data as GootiMetaData;
}
async loadBrowserSessionData(): Promise<BrowserSessionData | undefined> {
this.assureIsInitialized();
const data = await this.#browserSessionHandler.loadFullData();
if (Object.keys(data).length === 0) {
// No data available yet (e.g. because the vault was not unlocked).
return undefined;
}
// Set the existing data for in-memory usage.
this.#browserSessionHandler.setFullData(data as BrowserSessionData);
return data as BrowserSessionData;
}
/**
* Load and migrate the browser sync data. If no data is available yet,
* the returned object is undefined.
*/
async loadAndMigrateBrowserSyncData(): Promise<BrowserSyncData | undefined> {
this.assureIsInitialized();
const unmigratedBrowserSyncData =
await this.getBrowserSyncHandler().loadUnmigratedData();
const { browserSyncData, migrationWasPerformed } =
this.#migrateBrowserSyncData(unmigratedBrowserSyncData);
if (!browserSyncData) {
// Nothing to do at this point.
return undefined;
}
// There is data. Check, if it was migrated.
if (migrationWasPerformed) {
// Persist the migrated data back to the browser sync storage.
this.getBrowserSyncHandler().saveAndSetFullData(browserSyncData);
} else {
// Set the data for in-memory usage.
this.getBrowserSyncHandler().setFullData(browserSyncData);
}
return browserSyncData;
}
async deleteVault(doNotSetIsInitializedToFalse = false) {
await deleteVault.call(this, doNotSetIsInitializedToFalse);
}
async unlockVault(password: string): Promise<void> {
await unlockVault.call(this, password);
}
async createNewVault(password: string): Promise<void> {
await createNewVault.call(this, password);
}
async addIdentity(data: {
nick: string;
privkeyString: string;
}): Promise<void> {
await addIdentity.call(this, data);
}
async deleteIdentity(identityId: string | undefined): Promise<void> {
await deleteIdentity.call(this, identityId);
}
async switchIdentity(identityId: string | null): Promise<void> {
await switchIdentity.call(this, identityId);
}
async deletePermission(permissionId: string) {
await deletePermission.call(this, permissionId);
}
async addRelay(data: {
identityId: string;
url: string;
write: boolean;
read: boolean;
}): Promise<void> {
await addRelay.call(this, data);
}
async deleteRelay(relayId: string): Promise<void> {
await deleteRelay.call(this, relayId);
}
async updateRelay(relayClone: Relay_DECRYPTED): Promise<void> {
await updateRelay.call(this, relayClone);
}
exportVault(): string {
this.assureIsInitialized();
const vaultJson = JSON.stringify(
this.getBrowserSyncHandler().browserSyncData,
undefined,
4
);
return vaultJson;
}
async importVault(allegedBrowserSyncData: BrowserSyncData) {
this.assureIsInitialized();
const isValidData = this.#allegedBrowserSyncDataIsValid(
allegedBrowserSyncData
);
if (!isValidData) {
throw new Error('The imported data is not valid.');
}
await this.getBrowserSyncHandler().saveAndSetFullData(
allegedBrowserSyncData
);
}
getBrowserSyncHandler(): BrowserSyncHandler {
this.assureIsInitialized();
switch (this.#gootiMetaHandler.gootiMetaData?.syncFlow) {
case BrowserSyncFlow.NO_SYNC:
return this.#browserSyncNoHandler;
case BrowserSyncFlow.BROWSER_SYNC:
default:
return this.#browserSyncYesHandler;
}
}
getBrowserSessionHandler(): BrowserSessionHandler {
this.assureIsInitialized();
return this.#browserSessionHandler;
}
getGootiMetaHandler(): GootiMetaHandler {
this.assureIsInitialized();
return this.#gootiMetaHandler;
}
/**
* Throws an exception if the service is not initialized.
*/
assureIsInitialized(): void {
if (!this.isInitialized) {
throw new Error(
'StorageService is not initialized. Please call "initialize(...)" before doing anything else.'
);
}
}
async encrypt(value: string): Promise<string> {
const browserSessionData =
this.getBrowserSessionHandler().browserSessionData;
if (!browserSessionData || !browserSessionData.vaultPassword) {
throw new Error('Browser session data is undefined.');
}
return CryptoHelper.encrypt(
value,
browserSessionData.iv,
browserSessionData.vaultPassword
);
}
async decrypt(
value: string,
returnType: 'string' | 'number' | 'boolean'
): Promise<any> {
const browserSessionData =
this.getBrowserSessionHandler().browserSessionData;
if (!browserSessionData || !browserSessionData.vaultPassword) {
throw new Error('Browser session data is undefined.');
}
return this.decryptWithLockedVault(
value,
returnType,
browserSessionData.iv,
browserSessionData.vaultPassword
);
}
async decryptWithLockedVault(
value: string,
returnType: 'string' | 'number' | 'boolean',
iv: string,
password: string
): Promise<any> {
const decryptedValue = await CryptoHelper.decrypt(value, iv, password);
switch (returnType) {
case 'number':
return parseInt(decryptedValue);
case 'boolean':
return decryptedValue === 'true';
case 'string':
default:
return decryptedValue;
}
}
/**
* Migrate the browser sync data to the latest version.
*/
#migrateBrowserSyncData(browserSyncData: Partial<Record<string, any>>): {
browserSyncData?: BrowserSyncData;
migrationWasPerformed: boolean;
} {
if (Object.keys(browserSyncData).length === 0) {
// First run. There is no browser sync data yet.
return {
browserSyncData: undefined,
migrationWasPerformed: false,
};
}
// Will be implemented if migration is required.
return {
browserSyncData: browserSyncData as BrowserSyncData,
migrationWasPerformed: false,
};
}
#allegedBrowserSyncDataIsValid(data: BrowserSyncData): boolean {
if (typeof data.iv === 'undefined') {
return false;
}
if (typeof data.version !== 'number') {
return false;
}
if (typeof data.vaultHash === 'undefined') {
return false;
}
if (typeof data.selectedIdentityId === 'undefined') {
return false;
}
if (
typeof data.identities === 'undefined' ||
!Array.isArray(data.identities)
) {
return false;
}
if (
typeof data.permissions === 'undefined' ||
!Array.isArray(data.permissions)
) {
return false;
}
if (typeof data.relays === 'undefined' || !Array.isArray(data.relays)) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,84 @@
import { Nip07Method, Nip07MethodPolicy } from '@common';
export interface Permission_DECRYPTED {
id: string;
identityId: string;
host: string;
method: Nip07Method;
methodPolicy: Nip07MethodPolicy;
kind?: number;
}
export interface Permission_ENCRYPTED {
id: string;
identityId: string;
host: string;
method: string;
methodPolicy: string;
kind?: string;
}
export interface Identity_DECRYPTED {
id: string;
createdAt: string;
nick: string;
privkey: string;
}
export type Identity_ENCRYPTED = Identity_DECRYPTED;
export interface Relay_DECRYPTED {
id: string;
identityId: string;
url: string;
read: boolean;
write: boolean;
}
export interface Relay_ENCRYPTED {
id: string;
identityId: string;
url: string;
read: string;
write: string;
}
export interface BrowserSyncData_PART_Unencrypted {
version: number;
iv: string;
vaultHash: string;
}
export interface BrowserSyncData_PART_Encrypted {
selectedIdentityId: string | null;
permissions: Permission_ENCRYPTED[];
identities: Identity_ENCRYPTED[];
relays: Relay_ENCRYPTED[];
}
export type BrowserSyncData = BrowserSyncData_PART_Unencrypted &
BrowserSyncData_PART_Encrypted;
export enum BrowserSyncFlow {
NO_SYNC = 0,
BROWSER_SYNC = 1,
GOOTI_SYNC = 2,
CUSTOM_SYNC = 3,
}
export interface BrowserSessionData {
// The following properties purely come from the browser session storage
// and will never be going into the browser sync storage.
vaultPassword?: string;
// The following properties initially come from the browser sync storage.
iv: string;
permissions: Permission_DECRYPTED[];
identities: Identity_DECRYPTED[];
selectedIdentityId: string | null;
relays: Relay_DECRYPTED[];
}
export interface GootiMetaData {
syncFlow?: number; // 0 = no sync, 1 = browser sync, (future: 2 = Gooti sync, 3 = Custom sync (bring your own sync))
}