455 lines
13 KiB
TypeScript
455 lines
13 KiB
TypeScript
// Copyright 2019-2021 @polkadot/extension-bg authors & contributors
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
import type { MetadataDef, ProviderMeta } from '@polkadot/extension-inject/types';
|
|
import type { JsonRpcResponse, ProviderInterface, ProviderInterfaceCallback } from '@polkadot/rpc-provider/types';
|
|
import type { AccountJson, AuthorizeRequest, MetadataRequest, RequestAuthorizeTab, RequestRpcSend, RequestRpcSubscribe, RequestRpcUnsubscribe, RequestSign, ResponseRpcListProviders, ResponseSigning, SigningRequest } from '../types';
|
|
|
|
import { BehaviorSubject } from 'rxjs';
|
|
|
|
import { addMetadata, knownMetadata } from '@polkadot/extension-chains';
|
|
import chrome from '@polkadot/extension-inject/chrome';
|
|
import { assert } from '@polkadot/util';
|
|
|
|
import { MetadataStore } from '../../stores';
|
|
|
|
interface Resolver <T> {
|
|
reject: (error: Error) => void;
|
|
resolve: (result: T) => void;
|
|
}
|
|
|
|
interface AuthRequest extends Resolver<boolean> {
|
|
id: string;
|
|
idStr: string;
|
|
request: RequestAuthorizeTab;
|
|
url: string;
|
|
}
|
|
|
|
export type AuthUrls = Record<string, AuthUrlInfo>;
|
|
|
|
export interface AuthUrlInfo {
|
|
count: number;
|
|
id: string;
|
|
isAllowed: boolean;
|
|
origin: string;
|
|
url: string;
|
|
}
|
|
|
|
interface MetaRequest extends Resolver<boolean> {
|
|
id: string;
|
|
request: MetadataDef;
|
|
url: string;
|
|
}
|
|
|
|
// List of providers passed into constructor. This is the list of providers
|
|
// exposed by the extension.
|
|
type Providers = Record<string, {
|
|
meta: ProviderMeta;
|
|
// The provider is not running at init, calling this will instantiate the
|
|
// provider.
|
|
start: () => ProviderInterface;
|
|
}>
|
|
|
|
interface SignRequest extends Resolver<ResponseSigning> {
|
|
account: AccountJson;
|
|
id: string;
|
|
request: RequestSign;
|
|
url: string;
|
|
}
|
|
|
|
let idCounter = 0;
|
|
|
|
const WINDOW_OPTS: chrome.windows.CreateData = {
|
|
// This is not allowed on FF, only on Chrome - disable completely
|
|
// focused: true,
|
|
height: 621,
|
|
left: 150,
|
|
top: 150,
|
|
type: 'popup',
|
|
url: chrome.extension.getURL('notification.html'),
|
|
width: 560
|
|
};
|
|
|
|
const AUTH_URLS_KEY = 'authUrls';
|
|
|
|
function getId (): string {
|
|
return `${Date.now()}.${++idCounter}`;
|
|
}
|
|
|
|
export default class State {
|
|
readonly #authUrls: AuthUrls = {};
|
|
|
|
readonly #authRequests: Record<string, AuthRequest> = {};
|
|
|
|
readonly #metaStore = new MetadataStore();
|
|
|
|
// Map of providers currently injected in tabs
|
|
readonly #injectedProviders = new Map<chrome.runtime.Port, ProviderInterface>();
|
|
|
|
readonly #metaRequests: Record<string, MetaRequest> = {};
|
|
|
|
// Map of all providers exposed by the extension, they are retrievable by key
|
|
readonly #providers: Providers;
|
|
|
|
readonly #signRequests: Record<string, SignRequest> = {};
|
|
|
|
#windows: number[] = [];
|
|
|
|
public readonly authSubject: BehaviorSubject<AuthorizeRequest[]> = new BehaviorSubject<AuthorizeRequest[]>([]);
|
|
|
|
public readonly metaSubject: BehaviorSubject<MetadataRequest[]> = new BehaviorSubject<MetadataRequest[]>([]);
|
|
|
|
public readonly signSubject: BehaviorSubject<SigningRequest[]> = new BehaviorSubject<SigningRequest[]>([]);
|
|
|
|
constructor (providers: Providers = {}) {
|
|
this.#providers = providers;
|
|
|
|
this.#metaStore.all((_key: string, def: MetadataDef): void => {
|
|
addMetadata(def);
|
|
});
|
|
|
|
// retrieve previously set authorizations
|
|
const authString = localStorage.getItem(AUTH_URLS_KEY) || '{}';
|
|
const previousAuth = JSON.parse(authString) as AuthUrls;
|
|
|
|
this.#authUrls = previousAuth;
|
|
}
|
|
|
|
public get knownMetadata (): MetadataDef[] {
|
|
return knownMetadata();
|
|
}
|
|
|
|
public get numAuthRequests (): number {
|
|
return Object.keys(this.#authRequests).length;
|
|
}
|
|
|
|
public get numMetaRequests (): number {
|
|
return Object.keys(this.#metaRequests).length;
|
|
}
|
|
|
|
public get numSignRequests (): number {
|
|
return Object.keys(this.#signRequests).length;
|
|
}
|
|
|
|
public get allAuthRequests (): AuthorizeRequest[] {
|
|
return Object
|
|
.values(this.#authRequests)
|
|
.map(({ id, request, url }): AuthorizeRequest => ({ id, request, url }));
|
|
}
|
|
|
|
public get allMetaRequests (): MetadataRequest[] {
|
|
return Object
|
|
.values(this.#metaRequests)
|
|
.map(({ id, request, url }): MetadataRequest => ({ id, request, url }));
|
|
}
|
|
|
|
public get allSignRequests (): SigningRequest[] {
|
|
return Object
|
|
.values(this.#signRequests)
|
|
.map(({ account, id, request, url }): SigningRequest => ({ account, id, request, url }));
|
|
}
|
|
|
|
public get authUrls (): AuthUrls {
|
|
return this.#authUrls;
|
|
}
|
|
|
|
private popupClose (): void {
|
|
this.#windows.forEach((id: number): void =>
|
|
chrome.windows.remove(id)
|
|
);
|
|
this.#windows = [];
|
|
}
|
|
|
|
private popupOpen (): void {
|
|
chrome.windows.create({ ...WINDOW_OPTS }, (window?: chrome.windows.Window): void => {
|
|
if (window) {
|
|
this.#windows.push(window.id || 0);
|
|
}
|
|
});
|
|
}
|
|
|
|
private authComplete = (id: string, resolve: (result: boolean) => void, reject: (error: Error) => void): Resolver<boolean> => {
|
|
const complete = (result: boolean | Error) => {
|
|
const isAllowed = result === true;
|
|
const { idStr, request: { origin }, url } = this.#authRequests[id];
|
|
|
|
this.#authUrls[this.stripUrl(url)] = {
|
|
count: 0,
|
|
id: idStr,
|
|
isAllowed,
|
|
origin,
|
|
url
|
|
};
|
|
|
|
this.saveCurrentAuthList();
|
|
delete this.#authRequests[id];
|
|
this.updateIconAuth(true);
|
|
};
|
|
|
|
return {
|
|
reject: (error: Error): void => {
|
|
complete(error);
|
|
reject(error);
|
|
},
|
|
resolve: (result: boolean): void => {
|
|
complete(result);
|
|
resolve(result);
|
|
}
|
|
};
|
|
}
|
|
|
|
private saveCurrentAuthList () {
|
|
localStorage.setItem(AUTH_URLS_KEY, JSON.stringify(this.#authUrls));
|
|
}
|
|
|
|
private metaComplete = (id: string, resolve: (result: boolean) => void, reject: (error: Error) => void): Resolver<boolean> => {
|
|
const complete = (): void => {
|
|
delete this.#metaRequests[id];
|
|
this.updateIconMeta(true);
|
|
};
|
|
|
|
return {
|
|
reject: (error: Error): void => {
|
|
complete();
|
|
reject(error);
|
|
},
|
|
resolve: (result: boolean): void => {
|
|
complete();
|
|
resolve(result);
|
|
}
|
|
};
|
|
}
|
|
|
|
private signComplete = (id: string, resolve: (result: ResponseSigning) => void, reject: (error: Error) => void): Resolver<ResponseSigning> => {
|
|
const complete = (): void => {
|
|
delete this.#signRequests[id];
|
|
this.updateIconSign(true);
|
|
};
|
|
|
|
return {
|
|
reject: (error: Error): void => {
|
|
complete();
|
|
reject(error);
|
|
},
|
|
resolve: (result: ResponseSigning): void => {
|
|
complete();
|
|
resolve(result);
|
|
}
|
|
};
|
|
}
|
|
|
|
private stripUrl (url: string): string {
|
|
assert(url && (url.startsWith('http:') || url.startsWith('https:') || url.startsWith('ipfs:') || url.startsWith('ipns:')), `Invalid url ${url}, expected to start with http: or https: or ipfs: or ipns:`);
|
|
|
|
const parts = url.split('/');
|
|
|
|
return parts[2];
|
|
}
|
|
|
|
private updateIcon (shouldClose?: boolean): void {
|
|
const authCount = this.numAuthRequests;
|
|
const metaCount = this.numMetaRequests;
|
|
const signCount = this.numSignRequests;
|
|
const text = (
|
|
authCount
|
|
? 'Auth'
|
|
: metaCount
|
|
? 'Meta'
|
|
: (signCount ? `${signCount}` : '')
|
|
);
|
|
|
|
chrome.browserAction.setBadgeText({ text });
|
|
|
|
if (shouldClose && text === '') {
|
|
this.popupClose();
|
|
}
|
|
}
|
|
|
|
public toggleAuthorization (url: string): AuthUrls {
|
|
const entry = this.#authUrls[url];
|
|
|
|
assert(entry, `The source ${url} is not known`);
|
|
|
|
const newAllowState = !entry.isAllowed;
|
|
|
|
this.#authUrls[url].isAllowed = newAllowState;
|
|
|
|
return this.#authUrls;
|
|
}
|
|
|
|
private updateIconAuth (shouldClose?: boolean): void {
|
|
this.authSubject.next(this.allAuthRequests);
|
|
this.updateIcon(shouldClose);
|
|
}
|
|
|
|
private updateIconMeta (shouldClose?: boolean): void {
|
|
this.metaSubject.next(this.allMetaRequests);
|
|
this.updateIcon(shouldClose);
|
|
}
|
|
|
|
private updateIconSign (shouldClose?: boolean): void {
|
|
this.signSubject.next(this.allSignRequests);
|
|
this.updateIcon(shouldClose);
|
|
}
|
|
|
|
public async authorizeUrl (url: string, request: RequestAuthorizeTab): Promise<boolean> {
|
|
const idStr = this.stripUrl(url);
|
|
|
|
// Do not enqueue duplicate authorization requests.
|
|
const isDuplicate = Object.values(this.#authRequests)
|
|
.some((request) => request.idStr === idStr);
|
|
|
|
assert(!isDuplicate, `The source ${url} has a pending authorization request`);
|
|
|
|
if (this.#authUrls[idStr]) {
|
|
// this url was seen in the past
|
|
assert(this.#authUrls[idStr].isAllowed, `The source ${url} is not allowed to interact with this extension`);
|
|
|
|
return false;
|
|
}
|
|
|
|
return new Promise((resolve, reject): void => {
|
|
const id = getId();
|
|
|
|
this.#authRequests[id] = {
|
|
...this.authComplete(id, resolve, reject),
|
|
id,
|
|
idStr,
|
|
request,
|
|
url
|
|
};
|
|
|
|
this.updateIconAuth();
|
|
this.popupOpen();
|
|
});
|
|
}
|
|
|
|
public ensureUrlAuthorized (url: string): boolean {
|
|
const entry = this.#authUrls[this.stripUrl(url)];
|
|
|
|
assert(entry, `The source ${url} has not been enabled yet`);
|
|
assert(entry.isAllowed, `The source ${url} is not allowed to interact with this extension`);
|
|
|
|
return true;
|
|
}
|
|
|
|
public injectMetadata (url: string, request: MetadataDef): Promise<boolean> {
|
|
return new Promise((resolve, reject): void => {
|
|
const id = getId();
|
|
|
|
this.#metaRequests[id] = {
|
|
...this.metaComplete(id, resolve, reject),
|
|
id,
|
|
request,
|
|
url
|
|
};
|
|
|
|
this.updateIconMeta();
|
|
this.popupOpen();
|
|
});
|
|
}
|
|
|
|
public getAuthRequest (id: string): AuthRequest {
|
|
return this.#authRequests[id];
|
|
}
|
|
|
|
public getMetaRequest (id: string): MetaRequest {
|
|
return this.#metaRequests[id];
|
|
}
|
|
|
|
public getSignRequest (id: string): SignRequest {
|
|
return this.#signRequests[id];
|
|
}
|
|
|
|
// List all providers the extension is exposing
|
|
public rpcListProviders (): Promise<ResponseRpcListProviders> {
|
|
return Promise.resolve(Object.keys(this.#providers).reduce((acc, key) => {
|
|
acc[key] = this.#providers[key].meta;
|
|
|
|
return acc;
|
|
}, {} as ResponseRpcListProviders));
|
|
}
|
|
|
|
public rpcSend (request: RequestRpcSend, port: chrome.runtime.Port): Promise<JsonRpcResponse> {
|
|
const provider = this.#injectedProviders.get(port);
|
|
|
|
assert(provider, 'Cannot call pub(rpc.subscribe) before provider is set');
|
|
|
|
return provider.send(request.method, request.params);
|
|
}
|
|
|
|
// Start a provider, return its meta
|
|
public rpcStartProvider (key: string, port: chrome.runtime.Port): Promise<ProviderMeta> {
|
|
assert(Object.keys(this.#providers).includes(key), `Provider ${key} is not exposed by extension`);
|
|
|
|
if (this.#injectedProviders.get(port)) {
|
|
return Promise.resolve(this.#providers[key].meta);
|
|
}
|
|
|
|
// Instantiate the provider
|
|
this.#injectedProviders.set(port, this.#providers[key].start());
|
|
|
|
// Close provider connection when page is closed
|
|
port.onDisconnect.addListener((): void => {
|
|
const provider = this.#injectedProviders.get(port);
|
|
|
|
if (provider) {
|
|
provider.disconnect().catch(console.error);
|
|
}
|
|
|
|
this.#injectedProviders.delete(port);
|
|
});
|
|
|
|
return Promise.resolve(this.#providers[key].meta);
|
|
}
|
|
|
|
public rpcSubscribe ({ method, params, type }: RequestRpcSubscribe, cb: ProviderInterfaceCallback, port: chrome.runtime.Port): Promise<number | string> {
|
|
const provider = this.#injectedProviders.get(port);
|
|
|
|
assert(provider, 'Cannot call pub(rpc.subscribe) before provider is set');
|
|
|
|
return provider.subscribe(type, method, params, cb);
|
|
}
|
|
|
|
public rpcSubscribeConnected (_request: null, cb: ProviderInterfaceCallback, port: chrome.runtime.Port): void {
|
|
const provider = this.#injectedProviders.get(port);
|
|
|
|
assert(provider, 'Cannot call pub(rpc.subscribeConnected) before provider is set');
|
|
|
|
cb(null, provider.isConnected); // Immediately send back current isConnected
|
|
provider.on('connected', () => cb(null, true));
|
|
provider.on('disconnected', () => cb(null, false));
|
|
}
|
|
|
|
public rpcUnsubscribe (request: RequestRpcUnsubscribe, port: chrome.runtime.Port): Promise<boolean> {
|
|
const provider = this.#injectedProviders.get(port);
|
|
|
|
assert(provider, 'Cannot call pub(rpc.unsubscribe) before provider is set');
|
|
|
|
return provider.unsubscribe(request.type, request.method, request.subscriptionId);
|
|
}
|
|
|
|
public saveMetadata (meta: MetadataDef): void {
|
|
this.#metaStore.set(meta.genesisHash, meta);
|
|
|
|
addMetadata(meta);
|
|
}
|
|
|
|
public sign (url: string, request: RequestSign, account: AccountJson): Promise<ResponseSigning> {
|
|
const id = getId();
|
|
|
|
return new Promise((resolve, reject): void => {
|
|
this.#signRequests[id] = {
|
|
...this.signComplete(id, resolve, reject),
|
|
account,
|
|
id,
|
|
request,
|
|
url
|
|
};
|
|
|
|
this.updateIconSign();
|
|
this.popupOpen();
|
|
});
|
|
}
|
|
}
|