Skip to content
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

fix!: make scopes more restrictive #159

Merged
merged 10 commits into from
Jan 24, 2025
2 changes: 1 addition & 1 deletion packages/keyring-api/src/api/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export const KeyringAccountStruct = object({
address: string(),

/**
* Account supported scopes (CAIP-2 chain IDs).
* Account supported scopes (CAIP-2 chain IDs or CAIP-2 namespaces).
*/
scopes: nonempty(array(union([CaipNamespaceStruct, CaipChainIdStruct]))),

Expand Down
38 changes: 37 additions & 1 deletion packages/keyring-api/src/btc/types.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,20 @@
import { BtcP2wpkhAddressStruct } from './types';
import { BtcScopes } from './constants';
import type { BtcP2wpkhAccount } from './types';
import {
BtcMethod,
BtcP2wpkhAccountStruct,
BtcP2wpkhAddressStruct,
} from './types';
import { BtcAccountType } from '../api';

const MOCK_ACCOUNT = {
id: '55583f38-d81b-48f8-8494-fc543c2b5c95',
type: BtcAccountType.P2wpkh,
address: 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4',
methods: [BtcMethod.SendBitcoin],
options: {},
scopes: [BtcScopes.Mainnet],
};

describe('types', () => {
describe('BtcP2wpkhAddressStruct', () => {
Expand Down Expand Up @@ -37,5 +53,25 @@ describe('types', () => {
`${errorPrefix}: No separator character for ${address}`,
);
});

it('throws an error if there is multiple scopes', () => {
const account: BtcP2wpkhAccount = {
ccharly marked this conversation as resolved.
Show resolved Hide resolved
...MOCK_ACCOUNT,
scopes: [BtcScopes.Mainnet, BtcScopes.Testnet],
};
expect(() => BtcP2wpkhAccountStruct.assert(account)).toThrow(
'At path: scopes -- Expected a array with a length of `1` but received one with a length of `2`',
);
});

it('throws an error if there is no scope', () => {
const account: BtcP2wpkhAccount = {
...MOCK_ACCOUNT,
scopes: [],
};
expect(() => BtcP2wpkhAccountStruct.assert(account)).toThrow(
'At path: scopes -- Expected a array with a length of `1` but received one with a length of `0`',
);
});
});
});
17 changes: 16 additions & 1 deletion packages/keyring-api/src/btc/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { object } from '@metamask/keyring-utils';
import type { Infer } from '@metamask/superstruct';
import { string, array, enums, refine, literal } from '@metamask/superstruct';
import {
string,
array,
enums,
refine,
literal,
size,
} from '@metamask/superstruct';
import { CaipChainIdStruct } from '@metamask/utils';
import { bech32 } from 'bech32';

import { BtcAccountType, KeyringAccountStruct } from '../api';
Expand Down Expand Up @@ -41,6 +49,13 @@ export const BtcP2wpkhAccountStruct = object({
*/
type: literal(`${BtcAccountType.P2wpkh}`),

/**
* Account supported scope (CAIP-2 chain ID).
*
* NOTE: We consider a Bitcoin address to be valid on only 1 network at time.
*/
scopes: size(array(CaipChainIdStruct), 1),

/**
* Account supported methods.
*/
Expand Down
1 change: 1 addition & 0 deletions packages/keyring-api/src/eth/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@
export enum EthScopes {
Namespace = 'eip155',
Mainnet = 'eip155:1',
Testnet = 'eip155:11155111',
}
6 changes: 6 additions & 0 deletions packages/keyring-api/src/eth/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { object, definePattern } from '@metamask/keyring-utils';
import type { Infer } from '@metamask/superstruct';
import { nonempty, array, enums, literal } from '@metamask/superstruct';
import { CaipChainIdStruct } from '@metamask/utils';

import { EthScopes } from '.';
import { EthAccountType, KeyringAccountStruct } from '../api';
Expand Down Expand Up @@ -82,6 +83,11 @@ export const EthErc4337AccountStruct = object({
*/
type: literal(`${EthAccountType.Erc4337}`),

/**
* Account supported scopes (CAIP-2 chain IDs).
*/
scopes: nonempty(array(CaipChainIdStruct)),

/**
* Account supported methods.
*/
Expand Down
8 changes: 7 additions & 1 deletion packages/keyring-api/src/sol/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { object, definePattern } from '@metamask/keyring-utils';
import type { Infer } from '@metamask/superstruct';
import { array, enums, literal } from '@metamask/superstruct';
import { array, enums, literal, nonempty } from '@metamask/superstruct';
import { CaipChainIdStruct } from '@metamask/utils';

import { KeyringAccountStruct, SolAccountType } from '../api';

Expand Down Expand Up @@ -35,6 +36,11 @@ export const SolDataAccountStruct = object({
*/
type: literal(`${SolAccountType.DataAccount}`),

/**
* Account supported scopes (CAIP-2 chain IDs).
*/
scopes: nonempty(array(CaipChainIdStruct)),

/**
* Account supported methods.
*/
Expand Down
18 changes: 9 additions & 9 deletions packages/keyring-snap-bridge/src/SnapKeyring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ describe('SnapKeyring', () => {
};
const ethEoaAccount3 = {
id: 'c6697bcf-5710-4751-a1cb-340e4b50617a',
address: '0xab1G3q98V7C67T9103g30C0417610237A137d763'.toLowerCase(),
ccharly marked this conversation as resolved.
Show resolved Hide resolved
address: '0xf7bDe8609231033c69E502C08f85153f8A1548F2'.toLowerCase(),
options: {},
methods: ETH_EOA_METHODS,
scopes: [EthScopes.Namespace],
Expand All @@ -128,7 +128,7 @@ describe('SnapKeyring', () => {
address: '0x2f15b30952aebe0ed5fdbfe5bf16fb9ecdb31d9a'.toLowerCase(),
options: {},
methods: ETH_4337_METHODS,
scopes: [EthScopes.Namespace],
scopes: [EthScopes.Mainnet],
type: EthAccountType.Erc4337,
};
const btcP2wpkhAccount = {
Expand Down Expand Up @@ -163,7 +163,7 @@ describe('SnapKeyring', () => {
// For unknown accounts, we consider them as EVM EOA for now, so just re-use the
// same scopes.
scopes: [EthScopes.Namespace],
// This should be really possible to create such account, but since we potentially
// This should not be really possible to create such account, but since we potentially
// migrate data upon the Snap keyring initialization, we want to cover edge-cases
// like this one to avoid crashing and blocking everything...
type: 'unknown:type' as KeyringAccount['type'],
Expand Down Expand Up @@ -312,7 +312,7 @@ describe('SnapKeyring', () => {
params: {
account: {
...(ethEoaAccount1 as unknown as KeyringAccount),
address: '0x0',
address: ethEoaAccount2.address,
},
},
}),
Expand Down Expand Up @@ -447,7 +447,7 @@ describe('SnapKeyring', () => {
},
}),
).rejects.toThrow(
'Account scopes is required for non-EVM and ERC4337 accounts',
'At path: scopes -- Expected an array value, but received: undefined',
);
});

Expand Down Expand Up @@ -633,7 +633,7 @@ describe('SnapKeyring', () => {

it('fails when the EthMethod is not supported after update', async () => {
// Update first account to remove `EthMethod.PersonalSign`
let updatedMethods: EthMethod[] = Object.values(EthMethod).filter(
let updatedMethods: EthMethod[] = Object.values(ETH_EOA_METHODS).filter(
(method) => method !== EthMethod.PersonalSign,
);
expect(
Expand All @@ -656,7 +656,7 @@ describe('SnapKeyring', () => {
`Method '${EthMethod.PersonalSign}' not supported for account ${ethEoaAccount1.address}`,
);
// Restore `EthMethod.PersonalSign` and remove `EthMethod.SignTransaction`
updatedMethods = Object.values(EthMethod).filter(
ccharly marked this conversation as resolved.
Show resolved Hide resolved
updatedMethods = Object.values(ETH_EOA_METHODS).filter(
(method) => method !== EthMethod.SignTransaction,
);
expect(
Expand Down Expand Up @@ -723,7 +723,7 @@ describe('SnapKeyring', () => {
params: { account },
}),
).rejects.toThrow(
'Account scopes is required for non-EVM and ERC4337 accounts',
'At path: scopes -- Expected an array value, but received: undefined',
);
});

Expand All @@ -740,7 +740,7 @@ describe('SnapKeyring', () => {
params: { account },
}),
).rejects.toThrow(
'Account scopes is required for non-EVM and ERC4337 accounts',
'At path: scopes -- Expected an array value, but received: undefined',
);
});
});
Expand Down
17 changes: 6 additions & 11 deletions packages/keyring-snap-bridge/src/SnapKeyring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
import { EventEmitter } from 'events';
import { v4 as uuid } from 'uuid';

import { transformAccount } from './account';
import { DeferredPromise } from './DeferredPromise';
import {
AccountCreatedEventStruct,
Expand All @@ -52,11 +53,7 @@ import {
RequestRejectedEventStruct,
} from './events';
import { projectLogger as log } from './logger';
import {
isAccountV1,
migrateAccountV1,
transformAccountV1,
} from './migrations';
import { isAccountV1, migrateAccountV1 } from './migrations';
import { SnapIdMap } from './SnapIdMap';
import type {
SnapKeyringEvents,
Expand Down Expand Up @@ -208,9 +205,8 @@ export class SnapKeyring extends EventEmitter {
displayConfirmation,
} = message.params;

// To keep the retro-compatibility with older keyring-api versions, we mark some fields
// as optional and provide them here if they are missing.
const account = transformAccountV1(newAccountFromEvent);
// Potentially migrate the account.
const account = transformAccount(newAccountFromEvent);

// The UI still uses the account address to identify accounts, so we need
// to block the creation of duplicate accounts for now to prevent accounts
Expand Down Expand Up @@ -258,9 +254,8 @@ export class SnapKeyring extends EventEmitter {
this.#accounts.get(snapId, newAccountFromEvent.id) ??
throwError(`Account '${newAccountFromEvent.id}' not found`);

// To keep the retro-compatibility with older keyring-api versions, we mark some fields
// as optional and provide them here if they are missing.
const newAccount = transformAccountV1(newAccountFromEvent);
// Potentially migrate the account.
const newAccount = transformAccount(newAccountFromEvent);

// The address of the account cannot be changed. In the future, we will
// support changing the address of an account since it will be required to
Expand Down
18 changes: 18 additions & 0 deletions packages/keyring-snap-bridge/src/account.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { KeyringAccount } from '@metamask/keyring-api';

import { transformAccount } from './account';

describe('account', () => {
it('throws for unknown account type', () => {
const unknownAccount = {
// This should not be really possible to create such account, but since we potentially
// migrate data upon the Snap keyring initialization, we want to cover edge-cases
// like this one to avoid crashing and blocking everything...
type: 'unknown:type',
} as unknown as KeyringAccount; // Just testing the default case.

expect(() => transformAccount(unknownAccount)).toThrow(
"Unknown account type: 'unknown:type'",
);
});
});
73 changes: 71 additions & 2 deletions packages/keyring-snap-bridge/src/account.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
import { KeyringAccountStruct } from '@metamask/keyring-api';
import { omit, type Infer } from '@metamask/superstruct';
import type { KeyringAccount, KeyringAccountType } from '@metamask/keyring-api';
import {
BtcAccountType,
BtcP2wpkhAccountStruct,
EthAccountType,
EthEoaAccountStruct,
EthErc4337AccountStruct,
KeyringAccountStruct,
SolAccountType,
SolDataAccountStruct,
} from '@metamask/keyring-api';
import { assert, omit, type Infer } from '@metamask/superstruct';

import { isAccountV1, transformAccountV1 } from './migrations';

/**
* A `KeyringAccount` with some optional fields which can be used to keep
Expand All @@ -8,3 +20,60 @@ import { omit, type Infer } from '@metamask/superstruct';
export const KeyringAccountV1Struct = omit(KeyringAccountStruct, ['scopes']);

export type KeyringAccountV1 = Infer<typeof KeyringAccountV1Struct>;

/**
* Assert that an account-like object matches its actual account type.
*
* @param account - The account-like object.
* @returns The account as normal `KeyringAccount`.
*/
export function assertKeyringAccount<
Account extends { type: KeyringAccountType },
>(account: Account): KeyringAccount {
// TODO: We should use a `selectiveUnion` for this and probably use it to define
// the `KeyringAccount`. This would also required to have a "generic `KeyringAccount`"
// definition.
switch (account.type) {
case BtcAccountType.P2wpkh: {
assert(account, BtcP2wpkhAccountStruct);
return account;
}
case SolAccountType.DataAccount: {
assert(account, SolDataAccountStruct);
return account;
}
case EthAccountType.Erc4337: {
assert(account, EthErc4337AccountStruct);
return account;
}
case EthAccountType.Eoa: {
assert(account, EthEoaAccountStruct);
return account;
}
default: {
// For now, we cannot much more than this (this should also, never happen)!
// NOTE: We could use a "generic `KeyringAccount` type" here though.
throw new Error(`Unknown account type: '${account.type}'`);
}
}
}

/**
* Transform any versionned account to a `KeyringAccount`.
*
* @param accountToTransform - The account to transform.
* @returns A valid transformed `KeyringAccount`.
*/
export function transformAccount(
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
accountToTransform: KeyringAccountV1 | KeyringAccount,
): KeyringAccount {
// To keep the retro-compatibility with older keyring-api versions, we identify the account's
// version and transform it to the latest `KeyringAccount` representation.
const account = isAccountV1(accountToTransform)
? transformAccountV1(accountToTransform)
: accountToTransform;

// We still assert that the converted account is valid according to their account's type.
return assertKeyringAccount(account);
}
Loading
Loading