diff --git a/apps/extension/src/background/approvals/service.test.ts b/apps/extension/src/background/approvals/service.test.ts index 6bf84b5c7..a2e4f969d 100644 --- a/apps/extension/src/background/approvals/service.test.ts +++ b/apps/extension/src/background/approvals/service.test.ts @@ -3,6 +3,7 @@ import { chains } from "@namada/chains"; import { WrapperTxMsgValue } from "@namada/types"; import { ChainsService } from "background/chains"; import { KeyRingService } from "background/keyring"; +import { PermissionsService } from "background/permissions"; import { SdkService } from "background/sdk"; import { VaultService } from "background/vault"; import BigNumber from "bignumber.js"; @@ -49,6 +50,7 @@ describe("approvals service", () => { let sdkService: jest.Mocked; let keyRingService: jest.Mocked; let chainService: jest.Mocked; + let permissionsService: jest.Mocked; let dataStore: KVStoreMock; let txStore: KVStoreMock; let localStorage: LocalStorage; @@ -78,6 +80,7 @@ describe("approvals service", () => { keyRingService, vaultService, chainService, + permissionsService, broadcaster ); }); @@ -530,7 +533,7 @@ describe("approvals service", () => { describe("getResolver", () => { it("should get the related tab id resolver from resolverMap", async () => { const popupTabId = 1; - const resolver = { resolve: () => { }, reject: () => { } }; + const resolver = { resolve: () => {}, reject: () => {} }; service["resolverMap"] = { [popupTabId]: resolver, }; @@ -541,7 +544,7 @@ describe("approvals service", () => { it("should throw an error if there is no resolver for the tab id", async () => { const popupTabId = 1; service["resolverMap"] = { - [popupTabId]: { resolve: () => { }, reject: () => { } }, + [popupTabId]: { resolve: () => {}, reject: () => {} }, }; expect(() => service["getResolver"](999)).toThrow(); @@ -552,7 +555,7 @@ describe("approvals service", () => { it("should remove related tab id resolver from resolverMap", async () => { const popupTabId = 1; service["resolverMap"] = { - [popupTabId]: { resolve: () => { }, reject: () => { } }, + [popupTabId]: { resolve: () => {}, reject: () => {} }, }; service["removeResolver"](popupTabId); diff --git a/apps/extension/src/background/approvals/service.ts b/apps/extension/src/background/approvals/service.ts index 3519b6b0f..50e63c7b6 100644 --- a/apps/extension/src/background/approvals/service.ts +++ b/apps/extension/src/background/approvals/service.ts @@ -10,6 +10,7 @@ import { ResponseSign } from "@zondax/ledger-namada"; import { TopLevelRoute } from "Approvals/types"; import { ChainsService } from "background/chains"; import { KeyRingService } from "background/keyring"; +import { PermissionsService } from "background/permissions"; import { SdkService } from "background/sdk"; import { VaultService } from "background/vault"; import { ExtensionBroadcaster } from "extension"; @@ -36,6 +37,7 @@ export class ApprovalsService { protected readonly keyRingService: KeyRingService, protected readonly vaultService: VaultService, protected readonly chainService: ChainsService, + protected readonly permissionsService: PermissionsService, protected readonly broadcaster: ExtensionBroadcaster ) { browser.tabs.onRemoved.addListener((tabId) => { diff --git a/apps/extension/src/background/index.ts b/apps/extension/src/background/index.ts index 33e23ff75..4d8e77d62 100644 --- a/apps/extension/src/background/index.ts +++ b/apps/extension/src/background/index.ts @@ -24,6 +24,7 @@ import { LocalStorage, VaultStorage } from "storage"; import { ApprovalsService, init as initApprovals } from "./approvals"; import { ChainsService, init as initChains } from "./chains"; import { KeyRingService, UtilityStore, init as initKeyRing } from "./keyring"; +import { PermissionsService } from "./permissions"; import { SdkService } from "./sdk/service"; import { VaultService, init as initVault } from "./vault"; @@ -69,10 +70,12 @@ const init = new Promise(async (resolve) => { localStorage, broadcaster ); + const permissionsService = new PermissionsService(localStorage); const keyRingService = new KeyRingService( vaultService, sdkService, chainsService, + permissionsService, utilityStore, localStorage, vaultStorage, @@ -87,6 +90,7 @@ const init = new Promise(async (resolve) => { keyRingService, vaultService, chainsService, + permissionsService, broadcaster ); diff --git a/apps/extension/src/background/keyring/service.ts b/apps/extension/src/background/keyring/service.ts index d009f6023..330ec9e91 100644 --- a/apps/extension/src/background/keyring/service.ts +++ b/apps/extension/src/background/keyring/service.ts @@ -10,6 +10,7 @@ import { import { Result, truncateInMiddle } from "@namada/utils"; import { ChainsService } from "background/chains"; +import { PermissionsService } from "background/permissions"; import { SdkService } from "background/sdk/service"; import { VaultService } from "background/vault"; import { ExtensionBroadcaster, ExtensionRequester } from "extension"; @@ -32,6 +33,7 @@ export class KeyRingService { protected readonly vaultService: VaultService, protected readonly sdkService: SdkService, protected readonly chainsService: ChainsService, + protected readonly permissionsService: PermissionsService, protected readonly utilityStore: KVStore, protected readonly localStorage: LocalStorage, protected readonly vaultStorage: VaultStorage, diff --git a/apps/extension/src/background/permissions/index.ts b/apps/extension/src/background/permissions/index.ts new file mode 100644 index 000000000..6261f8963 --- /dev/null +++ b/apps/extension/src/background/permissions/index.ts @@ -0,0 +1 @@ +export * from "./service"; diff --git a/apps/extension/src/background/permissions/service.ts b/apps/extension/src/background/permissions/service.ts new file mode 100644 index 000000000..994f752e9 --- /dev/null +++ b/apps/extension/src/background/permissions/service.ts @@ -0,0 +1,59 @@ +import { AllowedPermissions, LocalStorage, PermissionKind } from "storage"; + +export class PermissionsService { + constructor(protected readonly localStorage: LocalStorage) {} + + async enablePermissions( + domain: string, + chainId: string, + allowed: AllowedPermissions + ): Promise { + const existingPermissions = await this.localStorage.getPermissions(); + const newPermissions = [...new Set(allowed)]; + + if (existingPermissions) { + existingPermissions[domain] = existingPermissions[domain] || {}; + existingPermissions[domain][chainId] = newPermissions; + return await this.localStorage.setPermissions(existingPermissions); + } + + return await this.localStorage.setPermissions({ + [domain]: { + [chainId]: newPermissions, + }, + }); + } + + async revokeChainPermissions(domain: string, chainId: string): Promise { + const updatedPermissions = await this.localStorage.getPermissions(); + if ( + !updatedPermissions || + !updatedPermissions[domain] || + !updatedPermissions[domain][chainId] + ) { + return; + } + delete updatedPermissions[domain][chainId]; + await this.localStorage.setPermissions(updatedPermissions); + } + + async permissionsByDomain( + domain: string + ): Promise | undefined> { + const permissions = await this.localStorage.getPermissions(); + + if (permissions && permissions[domain]) { + return permissions[domain]; + } + } + + async permissionsByChain( + domain: string, + chainId: string + ): Promise { + const permissions = await this.localStorage.getPermissions(); + if (permissions && permissions[domain] && permissions[domain][chainId]) { + return permissions[domain][chainId]; + } + } +} diff --git a/apps/extension/src/router/types/enums.ts b/apps/extension/src/router/types/enums.ts index 59f459873..71f1401e5 100644 --- a/apps/extension/src/router/types/enums.ts +++ b/apps/extension/src/router/types/enums.ts @@ -23,6 +23,7 @@ export enum KVPrefix { RevealedPK = "Namada::RevealedPK", SessionStorage = "Namada::SessionStorage", WasmHashesStorage = "Namada::WasmHashesStorage", + Permissions = "Namada::Permissions", } export enum KVKeys { diff --git a/apps/extension/src/storage/LocalStorage.ts b/apps/extension/src/storage/LocalStorage.ts index 6227574a3..a98fa751f 100644 --- a/apps/extension/src/storage/LocalStorage.ts +++ b/apps/extension/src/storage/LocalStorage.ts @@ -53,11 +53,42 @@ const Chain = t.intersection([ ]); type ChainType = t.TypeOf; +// TODO: Remove the folllowing once NamadaKeychainPermissions is working! const NamadaExtensionApprovedOrigins = t.array(t.string); type NamadaExtensionApprovedOriginsType = t.TypeOf< typeof NamadaExtensionApprovedOrigins >; +export type PermissionKind = "accounts" | "signing" | "proofGenKeys"; + +export const KeychainPermissions: Record< + PermissionKind, + { description: string } +> = { + accounts: { + description: "Allow approved clients to read account public data", + }, + signing: { description: "Allow approved clients to sign transactions" }, + proofGenKeys: { + description: + "Allow approved clients to request proof generation keys for shielded accounts", + }, +}; +export type AllowedPermissions = (keyof typeof KeychainPermissions)[]; + +// Define keychain permissions schema +const PermissionDomain = t.string; +const PermissionChainId = t.string; +const NamadaKeychainPermissions = t.record( + PermissionDomain, + t.record(PermissionChainId, t.array(t.keyof(KeychainPermissions))) +); + +// Export keychain permissions type +export type NamadaKeychainPermissionsType = t.TypeOf< + typeof NamadaKeychainPermissions +>; + const NamadaExtensionRouterId = t.number; type NamadaExtensionRouterIdType = t.TypeOf; @@ -69,18 +100,21 @@ type LocalStorageTypes = type LocalStorageSchemas = | typeof Chain | typeof NamadaExtensionApprovedOrigins - | typeof NamadaExtensionRouterId; + | typeof NamadaExtensionRouterId + | typeof NamadaKeychainPermissions; export type LocalStorageKeys = | "chains" | "namadaExtensionApprovedOrigins" | "namadaExtensionRouterId" + | "namadaKeychainPermissions" | "tabs"; const schemasMap = new Map([ [Chain, "chains"], [NamadaExtensionApprovedOrigins, "namadaExtensionApprovedOrigins"], [NamadaExtensionRouterId, "namadaExtensionRouterId"], + [NamadaKeychainPermissions, "namadaKeychainPermissions"], ]); export class LocalStorage extends ExtStorage { @@ -155,6 +189,34 @@ export class LocalStorage extends ExtStorage { await this.setRaw(this.getKey(NamadaExtensionApprovedOrigins), origins); } + async getPermissions(): Promise { + const data = await this.getRaw(this.getKey(NamadaKeychainPermissions)); + const Schema = t.union([NamadaKeychainPermissions, t.undefined]); + const decodedData = Schema.decode(data); + + if (E.isLeft(decodedData)) { + throw new Error(""); + } + return decodedData.right; + } + + async setPermissions( + permissions: NamadaKeychainPermissionsType + ): Promise { + // Validate permissions against schema + const Schema = t.union([NamadaKeychainPermissions, t.undefined]); + const decodedData = Schema.decode(permissions); + + if (E.isLeft(decodedData)) { + throw new Error("Invalid permissions data!"); + } + + await this.setRaw( + this.getKey(NamadaKeychainPermissions), + decodedData.right + ); + } + private getKey(schema: S): LocalStorageKeys { const key = schemasMap.get(schema); if (!key) { diff --git a/apps/extension/src/test/init.ts b/apps/extension/src/test/init.ts index c54192b86..1f1983e28 100644 --- a/apps/extension/src/test/init.ts +++ b/apps/extension/src/test/init.ts @@ -23,6 +23,7 @@ import { } from "../background/approvals"; import { ChainsService } from "background/chains"; +import { PermissionsService } from "background/permissions"; import { SdkService } from "background/sdk"; import { Namada } from "provider"; import { LocalStorage, VaultStorage } from "storage"; @@ -82,6 +83,7 @@ export const init = async (): Promise<{ ); const sdkService = await SdkService.init(localStorage); + const permissionsService = new PermissionsService(localStorage); const vaultService = new VaultService(vaultStorage, sessionStore, sdkService); await vaultService.initialize(); @@ -96,6 +98,7 @@ export const init = async (): Promise<{ vaultService, sdkService, chainsService, + permissionsService, utilityStore, localStorage, vaultStorage, @@ -111,6 +114,7 @@ export const init = async (): Promise<{ keyRingService, vaultService, chainsService, + permissionsService, broadcaster ); diff --git a/apps/namadillo/src/App/Common/ConnectExtensionButton.tsx b/apps/namadillo/src/App/Common/ConnectExtensionButton.tsx index c6a5e50aa..5a714460f 100644 --- a/apps/namadillo/src/App/Common/ConnectExtensionButton.tsx +++ b/apps/namadillo/src/App/Common/ConnectExtensionButton.tsx @@ -1,10 +1,13 @@ import { ActionButton } from "@namada/components"; +import { chainParametersAtom } from "atoms/chain"; import { namadaExtensionAttachStatus } from "atoms/settings"; import { useExtensionConnect } from "hooks/useExtensionConnect"; import { useAtomValue } from "jotai"; export const ConnectExtensionButton = (): JSX.Element => { const extensionAttachStatus = useAtomValue(namadaExtensionAttachStatus); + const { data: chain } = useAtomValue(chainParametersAtom); + const chainId = chain?.chainId; const { connect, isConnected } = useExtensionConnect(); // TODO create an action button when the extension is connected @@ -13,7 +16,11 @@ export const ConnectExtensionButton = (): JSX.Element => { return ( <> {extensionAttachStatus === "attached" && !isConnected && ( - + (chainId ? connect(chainId) : undefined)} + > Connect Keychain )} diff --git a/packages/integrations/src/Namada.ts b/packages/integrations/src/Namada.ts index 2d57ca490..7bf00a4e0 100644 --- a/packages/integrations/src/Namada.ts +++ b/packages/integrations/src/Namada.ts @@ -28,15 +28,24 @@ export default class Namada implements Integration { return !!this._namada; } - public async connect(chainId: string): Promise { + public async connect(chainId?: string): Promise { + if (!chainId) { + throw new Error("The Namada Keychain integration requires a chainId!"); + } await this._namada?.connect(chainId); } - public async disconnect(chainId: string): Promise { + public async disconnect(chainId?: string): Promise { + if (!chainId) { + throw new Error("The Namada Keychain integration requires a chainId!"); + } await this._namada?.disconnect(chainId); } - public async isConnected(chainId: string): Promise { + public async isConnected(chainId?: string): Promise { + if (!chainId) { + throw new Error("The Namada Keychain integration requires a chainId!"); + } return await this._namada?.isConnected(chainId); }