Skip to content

feat: track cip30 method call origin #1013

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Dec 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { AuthenticatorApi, Origin, RequestAccess } from './types';
import { Logger } from 'ts-log';
import { PersistentAuthenticatorStorage } from './PersistentAuthenticatorStorage';
import { Runtime } from 'webextension-polyfill';
import { senderOrigin } from '../util';

export interface PersistentAuhenticatorOptions {
requestAccess: RequestAccess;
Expand Down Expand Up @@ -32,13 +34,18 @@ export class PersistentAuthenticator implements AuthenticatorApi {
this.#originsReady = storage.get();
}

async requestAccess(origin: Origin) {
async requestAccess(sender: Runtime.MessageSender) {
const origin = senderOrigin(sender);
if (!origin) {
this.#logger.warn('Invalid sender url', sender);
return false;
}
const origins = await this.#originsReady;
if (origins.includes(origin)) {
return true;
}
try {
const accessGranted = await this.#requestAccess(origin);
const accessGranted = await this.#requestAccess(sender);
if (accessGranted) {
const newOrigins = [...origins, origin];
if (await this.#store(newOrigins)) {
Expand All @@ -52,7 +59,12 @@ export class PersistentAuthenticator implements AuthenticatorApi {
return false;
}

async revokeAccess(origin: Origin) {
async revokeAccess(sender: Runtime.MessageSender) {
const origin = senderOrigin(sender);
if (!origin) {
this.#logger.warn('Invalid sender url', sender);
return false;
}
const origins = await this.#originsReady;
const idx = origins.indexOf(origin);
if (idx >= 0) {
Expand All @@ -67,7 +79,8 @@ export class PersistentAuthenticator implements AuthenticatorApi {
return false;
}

async haveAccess(origin: Origin) {
async haveAccess(sender: Runtime.MessageSender) {
const origin = senderOrigin(sender);
if (!origin) return false;
const origins = await this.#originsReady;
return origins.includes(origin);
Expand Down
4 changes: 3 additions & 1 deletion packages/dapp-connector/src/AuthenticatorApi/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Runtime } from 'webextension-polyfill';

export type Origin = string;

/** Resolve true to authorise access to the WalletAPI, or resolve false to deny. Errors: `ApiError` */
export type RequestAccess = (origin: Origin) => Promise<boolean>;
export type RequestAccess = (sender: Runtime.MessageSender) => Promise<boolean>;
export type RevokeAccess = RequestAccess;
export type HaveAccess = RequestAccess;

Expand Down
8 changes: 8 additions & 0 deletions packages/dapp-connector/src/WalletApi/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Cardano } from '@cardano-sdk/core';
import { Ed25519PublicKeyHex } from '@cardano-sdk/crypto';
import { HexBlob } from '@cardano-sdk/util';
import { Runtime } from 'webextension-polyfill';

/** A hex-encoded string of the corresponding bytes. */
export type Bytes = string;
Expand Down Expand Up @@ -212,3 +213,10 @@ export interface CipExtensionApis {
}

export type Cip30WalletApiWithPossibleExtensions = Cip30WalletApi & Partial<CipExtensionApis>;

export type SenderContext = { sender: Runtime.MessageSender };
type FnWithSender<T> = T extends (...args: infer Args) => infer R ? (context: SenderContext, ...args: Args) => R : T;

export type WithSenderContext<T> = {
[K in keyof T]: FnWithSender<T[K]>;
};
1 change: 1 addition & 0 deletions packages/dapp-connector/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './errors';
export * from './WalletApi';
export * from './AuthenticatorApi';
export * from './injectGlobal';
export * from './util';
10 changes: 10 additions & 0 deletions packages/dapp-connector/src/util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Runtime } from 'webextension-polyfill';

export const senderOrigin = (sender?: Runtime.MessageSender): string | null => {
try {
const { origin } = new URL(sender?.url || 'throw');
return origin;
} catch {
return null;
}
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Origin, PersistentAuthenticator } from '../../src';
import { Origin, PersistentAuthenticator, senderOrigin } from '../../src';
import { Runtime } from 'webextension-polyfill';
import { dummyLogger } from 'ts-log';

const createStubStorage = () => {
Expand All @@ -12,8 +13,8 @@ const createStubStorage = () => {
};

describe('PersistentAuthenticator', () => {
const origin1: Origin = 'origin1';
const origin2: Origin = 'origin2';
const sender1: Runtime.MessageSender = { url: 'https://sender1.com' };
const sender2: Runtime.MessageSender = { url: 'https://sender2.com' };
let requestAccess: jest.Mock;
let storage: ReturnType<typeof createStubStorage>;
let authenticator: PersistentAuthenticator;
Expand All @@ -28,87 +29,87 @@ describe('PersistentAuthenticator', () => {
describe('requestAccess', () => {
it('resolves to true if allowed and persists the decision', async () => {
requestAccess.mockResolvedValueOnce(true);
expect(await authenticator.requestAccess(origin1)).toBe(true);
expect(await authenticator.requestAccess(origin1)).toBe(true);
expect(await authenticator.requestAccess(sender1)).toBe(true);
expect(await authenticator.requestAccess(sender1)).toBe(true);
expect(requestAccess).toBeCalledTimes(1);
expect(await storage.get()).toContain(origin1);
expect(await storage.get()).toContain(senderOrigin(sender1));
});

it('resolves to false if denied an error and does not persist the decision', async () => {
requestAccess.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
expect(await authenticator.requestAccess(origin1)).toBe(false);
expect(await storage.get()).not.toContain(origin1);
expect(await authenticator.requestAccess(origin1)).toBe(true);
expect(await storage.get()).toContain(origin1);
expect(await authenticator.requestAccess(sender1)).toBe(false);
expect(await storage.get()).not.toContain(senderOrigin(sender1));
expect(await authenticator.requestAccess(sender1)).toBe(true);
expect(await storage.get()).toContain(senderOrigin(sender1));
expect(requestAccess).toBeCalledTimes(2);
});

it('resolves to false if any error is encountered and does not persist the decision', async () => {
requestAccess.mockResolvedValue(true).mockRejectedValueOnce(new Error('any error'));
expect(await authenticator.requestAccess(origin1)).toBe(false);
expect(await storage.get()).not.toContain(origin1);
expect(await authenticator.requestAccess(sender1)).toBe(false);
expect(await storage.get()).not.toContain(senderOrigin(sender1));

storage.set.mockResolvedValue(void 0).mockRejectedValueOnce(new Error('any error'));
expect(await authenticator.requestAccess(origin1)).toBe(false);
expect(await storage.get()).not.toContain(origin1);
expect(await authenticator.requestAccess(sender1)).toBe(false);
expect(await storage.get()).not.toContain(senderOrigin(sender1));
});

it('caches storage by origin', async () => {
requestAccess.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
expect(await authenticator.requestAccess(origin1)).toBe(true);
expect(await authenticator.requestAccess(origin2)).toBe(false);
expect(await authenticator.requestAccess(sender1)).toBe(true);
expect(await authenticator.requestAccess(sender2)).toBe(false);
expect(requestAccess).toBeCalledTimes(2);
});
});

describe('revokeAccess', () => {
beforeEach(async () => {
requestAccess.mockResolvedValueOnce(true);
await authenticator.requestAccess(origin1);
await authenticator.requestAccess(sender1);
storage.set.mockReset();
});

it('unknown origin => returns false', async () => {
expect(await authenticator.revokeAccess(origin2)).toBe(false);
expect(await authenticator.revokeAccess(sender2)).toBe(false);
});

describe('allowed origin', () => {
it('returns true and removes origin from cache', async () => {
expect(await authenticator.revokeAccess(origin1)).toBe(true);
expect(await authenticator.revokeAccess(origin1)).toBe(false);
expect(await authenticator.revokeAccess(sender1)).toBe(true);
expect(await authenticator.revokeAccess(sender1)).toBe(false);
expect(storage.set).toBeCalledTimes(1);
});

it('returns false if storage throws', async () => {
storage.set.mockRejectedValueOnce(new Error('any error'));
expect(await authenticator.revokeAccess(origin1)).toBe(false);
expect(await authenticator.revokeAccess(sender1)).toBe(false);
});
});
});

describe('haveAccess', () => {
beforeEach(async () => {
requestAccess.mockResolvedValueOnce(true);
await authenticator.requestAccess(origin1);
await authenticator.requestAccess(sender1);
});

it('unknown origin => returns false', async () => {
expect(await authenticator.haveAccess(origin2)).toBe(false);
expect(await authenticator.haveAccess(sender2)).toBe(false);
});

it('allowed origin => returns true', async () => {
expect(await authenticator.haveAccess(origin1)).toBe(true);
expect(await authenticator.haveAccess(sender1)).toBe(true);
});
});

describe('clear', () => {
it('removes all origins', async () => {
requestAccess.mockResolvedValue(true);
await authenticator.requestAccess(origin1);
await authenticator.requestAccess(origin2);
await authenticator.requestAccess(sender1);
await authenticator.requestAccess(sender2);
await authenticator.clear();
expect(await authenticator.haveAccess(origin1)).toBe(false);
expect(await authenticator.haveAccess(origin2)).toBe(false);
expect(await authenticator.haveAccess(sender1)).toBe(false);
expect(await authenticator.haveAccess(sender2)).toBe(false);
});
});
});
13 changes: 13 additions & 0 deletions packages/dapp-connector/test/util.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { senderOrigin } from '../src';

describe('util', () => {
describe('senderOrigin', () => {
it('returns null when origin url is not present', () => {
expect(senderOrigin()).toBe(null);
expect(senderOrigin({ id: 'id' })).toBe(null);
});
it('returns origin url it is present', () => {
expect(senderOrigin({ url: 'http://origin' })).toBe('http://origin');
});
});
});
12 changes: 10 additions & 2 deletions packages/e2e/test/web-extension/extension/background/cip30.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,16 @@ import { walletName } from '../const';

// this should come from remote api
const confirmationCallback: walletCip30.CallbackConfirmation = {
signData: async () => true,
signTx: async () => true,
signData: async ({ sender }) => {
if (!sender) throw new Error('No sender context');
logger.info('signData request from', sender);
return true;
},
signTx: async ({ sender }) => {
if (!sender) throw new Error('No sender context');
logger.info('signTx request', sender);
return true;
},
submitTx: async () => true
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { RemoteApiPropertyType, consumeRemoteApi } from '@cardano-sdk/web-extension';
import { RequestAccess } from '@cardano-sdk/dapp-connector';
import { RequestAccess, senderOrigin } from '@cardano-sdk/dapp-connector';
import { UserPromptService, logger } from '../util';
import { ensureUiIsOpenAndLoaded } from './windowManager';
import { runtime } from 'webextension-polyfill';
Expand All @@ -15,7 +15,9 @@ const userPromptService = consumeRemoteApi<UserPromptService>(
{ logger, runtime }
);

export const requestAccess: RequestAccess = async (origin) => {
export const requestAccess: RequestAccess = async (sender) => {
const origin = senderOrigin(sender);
if (!origin) throw new Error('Invalid requestAccess request: unknown sender origin');
await ensureUiIsOpenAndLoaded();
return await userPromptService.allowOrigin(origin);
};
1 change: 1 addition & 0 deletions packages/key-management/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"devDependencies": {
"@types/lodash": "^4.14.182",
"@types/pbkdf2": "^3.1.0",
"@types/webextension-polyfill": "^0.8.0",
"eslint": "^7.32.0",
"jest": "^28.1.3",
"madge": "^5.0.1",
Expand Down
13 changes: 8 additions & 5 deletions packages/key-management/src/cip8/cip30signData.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as Crypto from '@cardano-sdk/crypto';
import { AccountKeyDerivationPath, GroupedAddress, KeyRole } from '../types';
import { AccountKeyDerivationPath, GroupedAddress, KeyRole, MessageSender } from '../types';
import {
AlgorithmId,
CBORValue,
Expand All @@ -24,6 +24,7 @@ export interface Cip30SignDataRequest {
witnesser: Bip32Ed25519Witnesser;
signWith: Cardano.PaymentAddress | Cardano.RewardAccount | Cardano.DRepID;
payload: HexBlob;
sender?: MessageSender;
}

export enum Cip30DataSignErrorCode {
Expand Down Expand Up @@ -87,10 +88,11 @@ const createSigStructureHeaders = (addressBytes: Uint8Array) => {
const signSigStructure = (
witnesser: Bip32Ed25519Witnesser,
derivationPath: AccountKeyDerivationPath,
sigStructure: SigStructure
sigStructure: SigStructure,
sender?: MessageSender
) => {
try {
return witnesser.signBlob(derivationPath, util.bytesToHex(sigStructure.to_bytes()));
return witnesser.signBlob(derivationPath, util.bytesToHex(sigStructure.to_bytes()), sender);
} catch (error) {
throw new Cip30DataSignError(Cip30DataSignErrorCode.UserDeclined, 'Failed to sign', error);
}
Expand All @@ -115,7 +117,8 @@ export const cip30signData = async ({
knownAddresses,
witnesser,
signWith,
payload
payload,
sender
}: Cip30SignDataRequest): Promise<Cip30DataSignature> => {
if (Cardano.DRepID.isValid(signWith) && !Cardano.DRepID.canSign(signWith)) {
throw new Cip30DataSignError(Cip30DataSignErrorCode.AddressNotPK, 'Invalid address');
Expand All @@ -129,7 +132,7 @@ export const cip30signData = async ({
false
);
const sigStructure = builder.make_data_to_sign();
const { signature, publicKey } = await signSigStructure(witnesser, derivationPath, sigStructure);
const { signature, publicKey } = await signSigStructure(witnesser, derivationPath, sigStructure, sender);
const coseSign1 = builder.build(Buffer.from(signature, 'hex'));

const coseKey = createCoseKey(addressBytes, publicKey);
Expand Down
6 changes: 5 additions & 1 deletion packages/key-management/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import * as Crypto from '@cardano-sdk/crypto';
import { Cardano } from '@cardano-sdk/core';
import { HexBlob, OpaqueString, Shutdown } from '@cardano-sdk/util';
import { Logger } from 'ts-log';
import type { Runtime } from 'webextension-polyfill';

export type MessageSender = Runtime.MessageSender;

export interface SignBlobResult {
publicKey: Crypto.Ed25519PublicKeyHex;
Expand Down Expand Up @@ -141,6 +144,7 @@ export interface SignTransactionOptions {
export interface SignTransactionContext {
txInKeyPathMap: TxInKeyPathMap;
knownAddresses: GroupedAddress[];
sender?: MessageSender;
}

export interface KeyAgent {
Expand Down Expand Up @@ -231,5 +235,5 @@ export interface Witnesser {
/**
* @throws AuthenticationError
*/
signBlob(derivationPath: AccountKeyDerivationPath, blob: HexBlob): Promise<SignBlobResult>;
signBlob(derivationPath: AccountKeyDerivationPath, blob: HexBlob, sender?: MessageSender): Promise<SignBlobResult>;
}
7 changes: 6 additions & 1 deletion packages/key-management/src/util/createWitnesser.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
AccountKeyDerivationPath,
AsyncKeyAgent,
MessageSender,
SignBlobResult,
SignTransactionContext,
WitnessOptions,
Expand All @@ -25,7 +26,11 @@ export class Bip32Ed25519Witnesser implements Witnesser {
return { signatures: await this.#keyAgent.signTransaction(txInternals, context, options) };
}

async signBlob(derivationPath: AccountKeyDerivationPath, blob: HexBlob): Promise<SignBlobResult> {
async signBlob(
derivationPath: AccountKeyDerivationPath,
blob: HexBlob,
_sender?: MessageSender
): Promise<SignBlobResult> {
return this.#keyAgent.signBlob(derivationPath, blob);
}
}
Expand Down
Loading