Introduce the enable() interface (prompts user) (#20)

* Introduce the enable() interface

* Provider

* Add auth requests to state

* injector comments

* Background extension list, spprove & reject

* Cleanups

* Untested, but the UI parts are there (test next)

* linting

* Not pretty, but seems to auth

* Small styling adjustments

* Adjust Box layout slightly

* Small styling adjustments

* signing -> transactions

* isDanger for links

* Styling defaults
This commit is contained in:
Jaco Greeff 2019-05-23 12:43:55 +02:00 committed by GitHub
parent 4a2768cee8
commit f241e7191d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 582 additions and 247 deletions

View File

@ -24,29 +24,45 @@ Once added, you can create an account (via a generated seed) or import via an ex
The extension injects `injectedWeb3` into the global `window` object, exposing the following:
```js
// a version that identifies the actual injection version (future-use)
type Version = 0;
window.injectedWeb3 = {
// this is the name for this extension, there could be multiples injected,
// each with their own keys
'polkadot-js': {
// same as above, just easier to manage having accessible here
name: 'polkadot-js',
// semver for the package
version: '0.1.0',
// an interface describing an account
interface Account {
readonly address: string; // ss-58 encoded address
readonly name?: string; // optional name for display
// this is called to enable the injection, and returns an injected
// object containing the accounts, signer and provider interfaces
// (or it will reject if not authorized)
enable (originName: string): Promise<Injected>
}
}
```
When there is more than one extension, each will populate an entry above, so from an extension implementation perspective, the structure should not be overridded. The injected interface, when returned via `enable`, contains the following -
```js
interface Injected {
readonly accounts: Accounts;
readonly signer: Signer;
// not injected as of yet, subscriptable provider for polkadot-js injection
// readonly provider: Provider
}
// exposes accounts
interface Accounts {
get: () => Promise<Array<Account>>;
get (): Promise<Array<{
readonly address: string; // ss-58 encoded address
readonly name?: string; // optional name for display
}>>;
}
// a signer that communicates with the extension via sendMessage
interface Signer extends SignerInterface {
// no specific signer extensions
}
interface Injected {
readonly accounts: Accounts;
readonly signer: Signer;
readonly version: Version;
// no specific signer extensions, exposes the `sign` interface for use by
// the polkadot-js API
}
```

View File

@ -5,9 +5,8 @@
import { OnActionFromCtx } from '../../components/types';
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { ActionBar, Address, withAction } from '../../components';
import { ActionBar, Address, Link, withOnAction } from '../../components';
import { editAccount } from '../../messaging';
import { Name } from '../../partials';
@ -53,11 +52,11 @@ function Account ({ address, className, onAction }: Props) {
}
>
<ActionBar>
<a href='#' onClick={toggleEdit}>Edit</a>
<Link onClick={toggleEdit}>Edit</Link>
<Link to={`/account/forget/${address}`}>Forget</Link>
</ActionBar>
</Address>
);
}
export default withAction(Account);
export default withOnAction(Account);

View File

@ -0,0 +1,56 @@
// Copyright 2019 @polkadot/extension-ui authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import { MessageAuthorize } from '@polkadot/extension/background/types';
import { OnActionFromCtx } from '../../components/types';
import React from 'react';
import styled from 'styled-components';
import { ActionBar, Box, Button, Link, defaults, withOnAction } from '../../components';
import { approveAuthRequest, rejectAuthRequest } from '../../messaging';
type Props = {
authId: number,
className?: string,
isFirst: boolean,
onAction: OnActionFromCtx,
request: MessageAuthorize,
url: string
};
function Request ({ authId, className, isFirst, onAction, request: { origin }, url }: Props) {
const onApprove = (): Promise<void> =>
approveAuthRequest(authId)
.then(() => onAction())
.catch(console.error);
const onReject = (): void => {
rejectAuthRequest(authId)
.then(() => onAction())
.catch(console.error);
};
return (
<Box className={className}>
<div>The application, identified as <div className='tab-name'>{origin}</div> is requesting access from <div className='tab-url'>{url}</div> to the accounts and signing capabilities of this extension. Only approve the request if you trust the application.</div>
<ActionBar>
<Link isDanger onClick={onReject}>Reject</Link>
</ActionBar>
{isFirst && (
<Button
label='Yes, allow this application access'
onClick={onApprove}
/>
)}
</Box>
);
}
export default withOnAction(styled(Request)`
.tab-name,
.tab-url {
color: ${defaults.linkColor};
display: inline-block;
}
`);

View File

@ -0,0 +1,33 @@
// Copyright 2019 @polkadot/extension-ui authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import { AuthRequestsFromCtx } from '../../components/types';
import React from 'react';
import { Header, withAuthRequests } from '../../components';
import Request from './Request';
type Props = {
requests: AuthRequestsFromCtx
};
function Authorize ({ requests }: Props) {
return (
<div>
<Header label='authorize' />
{requests.map(([id, request, url], index) => (
<Request
authId={id}
isFirst={index === 0}
key={id}
request={request}
url={url}
/>
))}
</div>
);
}
export default withAuthRequests(Authorize);

View File

@ -6,7 +6,7 @@ import { OnActionFromCtx } from '../components/types';
import React, { useState, useEffect } from 'react';
import { Address, Button, Header, Loading, TextArea, withAction } from '../components';
import { Address, Button, Header, Loading, TextArea, withOnAction } from '../components';
import { createAccount, createSeed } from '../messaging';
import { Back, Name, Password } from '../partials';
@ -69,4 +69,4 @@ function Create ({ onAction }: Props) {
);
}
export default withAction(Create);
export default withOnAction(Create);

View File

@ -7,7 +7,7 @@ import { OnActionFromCtx } from '../components/types';
import React from 'react';
import { RouteComponentProps, withRouter } from 'react-router';
import { Address, Button, Header, Tip, withAction } from '../components';
import { Address, Button, Header, Tip, withOnAction } from '../components';
import { forgetAccount } from '../messaging';
import { Back } from '../partials';
@ -37,4 +37,4 @@ function Forget ({ match: { params: { address } }, onAction }: Props) {
);
}
export default withAction(withRouter(Forget));
export default withOnAction(withRouter(Forget));

View File

@ -6,7 +6,7 @@ import { OnActionFromCtx } from '../components/types';
import React, { useState } from 'react';
import { Address, Button, Header, TextArea, withAction } from '../components';
import { Address, Button, Header, TextArea, withOnAction } from '../components';
import { createAccount, validateSeed } from '../messaging';
import { Back, Name, Password } from '../partials';
@ -67,4 +67,4 @@ function Import ({ onAction }: Props) {
);
}
export default withAction(Import);
export default withOnAction(Import);

View File

@ -7,8 +7,8 @@ import { OnActionFromCtx } from '../../components/types';
import React from 'react';
import { ActionBar, Address, withAction } from '../../components';
import { approveRequest, cancelRequest } from '../../messaging';
import { ActionBar, Address, Link, withOnAction } from '../../components';
import { approveSignRequest, cancelSignRequest } from '../../messaging';
import Details from './Details';
import Unlock from './Unlock';
@ -22,12 +22,12 @@ type Props = {
function Request ({ isFirst, onAction, request: { address, genesisHash, method, nonce }, signId, url }: Props) {
const onCancel = (): void => {
cancelRequest(signId)
cancelSignRequest(signId)
.then(() => onAction())
.catch(console.error);
};
const onSign = (password: string): Promise<void> =>
approveRequest(signId, password).then(() => onAction());
approveSignRequest(signId, password).then(() => onAction());
return (
<Address address={address}>
@ -38,11 +38,11 @@ function Request ({ isFirst, onAction, request: { address, genesisHash, method,
url={url}
/>
<ActionBar>
<a href='#' onClick={onCancel}>Cancel</a>
<Link isDanger onClick={onCancel}>Cancel</Link>
</ActionBar>
{isFirst && <Unlock onSign={onSign} />}
</Address>
);
}
export default withAction(Request);
export default withOnAction(Request);

View File

@ -48,4 +48,5 @@ function Unlock ({ className, onSign }: Props) {
export default styled(Unlock)`
margin-bottom: -0.75rem;
margin-left: -3rem;
`;

View File

@ -2,21 +2,21 @@
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import { RequestsFromCtx } from '../../components/types';
import { SignRequestsFromCtx } from '../../components/types';
import React from 'react';
import { Header, withRequests } from '../../components';
import { Header, withSignRequests } from '../../components';
import Request from './Request';
type Props = {
requests: RequestsFromCtx
requests: SignRequestsFromCtx
};
function Signing ({ requests }: Props) {
return (
<div>
<Header label='requests' />
<Header label='transactions' />
{requests.map(([id, request, url], index) => (
<Request
isFirst={index === 0}
@ -30,4 +30,4 @@ function Signing ({ requests }: Props) {
);
}
export default withRequests(Signing);
export default withSignRequests(Signing);

View File

@ -2,16 +2,17 @@
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import { MessageExtrinsicSign } from '@polkadot/extension/background/types';
import { AuthorizeRequest, SigningRequest } from '@polkadot/extension/background/types';
import { KeyringJson } from '@polkadot/ui-keyring/types';
import React, { useEffect, useState } from 'react';
import { Route, Switch } from 'react-router';
import { Loading } from '../components';
import { AccountContext, ActionContext, SigningContext } from '../components/contexts';
import { getAccounts, getRequests } from '../messaging';
import { AccountContext, ActionContext, AuthorizeContext, SigningContext } from '../components/contexts';
import { getAccounts, getAuthRequests, getSignRequests } from '../messaging';
import Accounts from './Accounts';
import Authorize from './Authorize';
import Create from './Create';
import Forget from './Forget';
import Import from './Import';
@ -21,15 +22,17 @@ type Props = {};
export default function Popup (props: Props) {
const [accounts, setAccounts] = useState(null as null | Array<KeyringJson>);
const [requests, setRequests] = useState(null as null | Array<[number, MessageExtrinsicSign, string]>);
const [authRequests, setAuthRequests] = useState(null as null | Array<AuthorizeRequest>);
const [signRequests, setSignRequests] = useState(null as null | Array<SigningRequest>);
const onAction = (to?: string): void => {
// loads all accounts & requests (this is passed through to children to trigger changes)
Promise
.all([getAccounts(), getRequests()])
.then(([accounts, requests]) => {
.all([getAccounts(), getAuthRequests(), getSignRequests()])
.then(([accounts, authRequests, signRequests]) => {
setAccounts(accounts);
setRequests(requests);
setAuthRequests(authRequests);
setSignRequests(signRequests);
})
.catch(console.error);
@ -43,18 +46,26 @@ export default function Popup (props: Props) {
onAction();
}, []);
const Root = authRequests && authRequests.length
? Authorize
: signRequests && signRequests.length
? Signing
: Accounts;
return (
<Loading>{accounts && requests && (
<Loading>{accounts && authRequests && signRequests && (
<ActionContext.Provider value={onAction}>
<AccountContext.Provider value={accounts}>
<SigningContext.Provider value={requests}>
<Switch>
<Route path='/account/create' component={Create} />
<Route path='/account/forget/:address' component={Forget} />
<Route path='/account/import' component={Import} />
<Route exact path='/' component={requests.length ? Signing : Accounts} />
</Switch>
</SigningContext.Provider>
<AuthorizeContext.Provider value={authRequests}>
<SigningContext.Provider value={signRequests}>
<Switch>
<Route path='/account/create' component={Create} />
<Route path='/account/forget/:address' component={Forget} />
<Route path='/account/import' component={Import} />
<Route exact path='/' component={Root} />
</Switch>
</SigningContext.Provider>
</AuthorizeContext.Provider>
</AccountContext.Provider>
</ActionContext.Provider>
)}</Loading>

View File

@ -21,9 +21,10 @@ function Box ({ children, className }: Props) {
}
export default styled(Box)`
background: transparent;
border-top: ${defaults.hdrBorder};
background: white;
border: ${defaults.boxBorder};
border-radius: ${defaults.borderRadius};
box-shadow: ${defaults.boxShadow};
color: ${defaults.color};
font-family: ${defaults.fontFamily};
font-size: ${defaults.fontSize};

View File

@ -23,7 +23,6 @@ function Header ({ children, className, label }: Props) {
export default styled(Header)`
background: ${defaults.hdrBg};
border-top: ${defaults.hdrBorder};
box-sizing: border-box;
color: ${defaults.hdrColor};
font-family: ${defaults.fontFamily};

View File

@ -0,0 +1,52 @@
// Copyright 2019 @polkadot/extension-ui authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import React from 'react';
import styled from 'styled-components';
import { Link as RouterLink } from 'react-router-dom';
import defaults from './defaults';
type Props = {
children?: React.ReactNode,
className?: string,
isDanger?: boolean,
onClick?: () => void,
to?: string
};
function Link ({ children, className, onClick, to }: Props) {
return (
to
? <RouterLink className={className} onClick={onClick} to={to}>{children}</RouterLink>
: <a href='#' className={className} onClick={onClick} >{children}</a>
);
}
export default styled(Link)`
color: ${({ isDanger }) =>
isDanger
? defaults.linkColorDanger
: defaults.linkColor
};
opacity: 0.9;
text-decoration: none;
&:hover {
color: ${({ isDanger }) =>
isDanger
? defaults.linkColorDanger
: defaults.linkColor
};
opacity: 1.0;
}
&:visited {
color: ${({ isDanger }) =>
isDanger
? defaults.linkColorDanger
: defaults.linkColor
};
}
`;

View File

@ -42,6 +42,7 @@ export default styled(Tip)`
border-left: 0.25rem solid ${(props) =>
getColor(props).border
};
box-shadow: ${defaults.boxShadow};
color: ${(props) =>
getColor(props).color
};

View File

@ -26,21 +26,6 @@ export default styled(View)`
font-size: ${defaults.fontSize};
line-height: ${defaults.lineHeight};
a {
color: ${defaults.linkColor};
opacity: 0.9;
text-decoration: none;
&:hover {
color: ${defaults.linkColor};
opacity: 1.0;
}
&:visited {
color: ${defaults.linkColor};
}
}
h3 {
margin: 0 0 0.75rem;
text-transform: uppercase;

View File

@ -2,7 +2,7 @@
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import { AccountsFromCtx, OnActionFromCtx, RequestsFromCtx, SubtractProps } from './types';
import { AccountsFromCtx, OnActionFromCtx, AuthRequestsFromCtx, SignRequestsFromCtx, SubtractProps } from './types';
import React from 'react';
@ -12,11 +12,13 @@ const noop = (to?: string) => {
const AccountContext = React.createContext([] as AccountsFromCtx);
const ActionContext = React.createContext(noop as OnActionFromCtx);
const SigningContext = React.createContext([] as RequestsFromCtx);
const AuthorizeContext = React.createContext([] as AuthRequestsFromCtx);
const SigningContext = React.createContext([] as SignRequestsFromCtx);
export {
AccountContext,
ActionContext,
AuthorizeContext,
SigningContext
};
@ -36,7 +38,7 @@ export function withAccounts<P extends { accounts: AccountsFromCtx }> (Component
};
}
export function withAction<P extends { onAction: OnActionFromCtx }> (Component: React.ComponentType<P>): React.ComponentType<SubtractProps<P, { onAction: OnActionFromCtx }>> {
export function withOnAction<P extends { onAction: OnActionFromCtx }> (Component: React.ComponentType<P>): React.ComponentType<SubtractProps<P, { onAction: OnActionFromCtx }>> {
return (props: SubtractProps<P, { onAction: OnActionFromCtx }>) => {
return (
<ActionContext.Consumer>
@ -52,8 +54,24 @@ export function withAction<P extends { onAction: OnActionFromCtx }> (Component:
};
}
export function withRequests<P extends { requests: RequestsFromCtx }> (Component: React.ComponentType<P>): React.ComponentType<SubtractProps<P, { requests: RequestsFromCtx }>> {
return (props: SubtractProps<P, { requests: RequestsFromCtx }>) => {
export function withAuthRequests<P extends { requests: AuthRequestsFromCtx }> (Component: React.ComponentType<P>): React.ComponentType<SubtractProps<P, { requests: AuthRequestsFromCtx }>> {
return (props: SubtractProps<P, { requests: AuthRequestsFromCtx }>) => {
return (
<AuthorizeContext.Consumer>
{(requests) => (
// @ts-ignore Something here with the props are going wonky
<Component
{...props}
requests={requests}
/>
)}
</AuthorizeContext.Consumer>
);
};
}
export function withSignRequests<P extends { requests: SignRequestsFromCtx }> (Component: React.ComponentType<P>): React.ComponentType<SubtractProps<P, { requests: SignRequestsFromCtx }>> {
return (props: SubtractProps<P, { requests: SignRequestsFromCtx }>) => {
return (
<SigningContext.Consumer>
{(requests) => (

View File

@ -14,19 +14,21 @@ const defaults: { [index: string]: any } = {
btnColor: LINK_COLOR,
btnColorDanger: DANGER_COLOR,
btnPadding: '0.75rem 1rem',
boxBorder: '0.25rem solid #e2e1e0',
boxMargin: '0.75rem 0',
boxPadding: '0 0.25rem',
boxShadow: 'none', // '0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24)',
color: TEXT_COLOR,
colorLabel: LABEL_COLOR,
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
fontSize: '1rem',
hdrBg: 'transparent',
hdrBorder: '0.25rem solid #eee',
hdrColor: '#ccc',
hdrBg: '#e2e1e0',
hdrColor: '#878786',
inputBorder: '#ccc',
inputPadding: '0.5rem 0.75rem',
lineHeight: '1.25',
linkColor: LINK_COLOR,
linkColorDanger: DANGER_COLOR,
box: {
error: {
background: '#ffe6e6',

View File

@ -8,6 +8,7 @@ export { default as Box } from './Box';
export { default as Button } from './Button';
export { default as Header } from './Header';
export { default as Input } from './Input';
export { default as Link } from './Link';
export { default as Loading } from './Loading';
export { default as TextArea } from './TextArea';
export { default as Tip } from './Tip';

View File

@ -2,7 +2,7 @@
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import { SigningRequest } from '@polkadot/extension/background/types';
import { AuthorizeRequest, SigningRequest } from '@polkadot/extension/background/types';
import { KeyringJson } from '@polkadot/ui-keyring/types';
export type OmitProps<T, K> = Pick<T, Exclude<keyof T, K>>;
@ -10,4 +10,5 @@ export type SubtractProps<T, K> = OmitProps<T, keyof K>;
export type AccountsFromCtx = Array<KeyringJson>;
export type OnActionFromCtx = (to?: string) => void;
export type RequestsFromCtx = Array<SigningRequest>;
export type AuthRequestsFromCtx = Array<AuthorizeRequest>;
export type SignRequestsFromCtx = Array<SigningRequest>;

View File

@ -2,7 +2,7 @@
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import { MessageTypes, MessageExtrinsicSign } from '@polkadot/extension/background/types';
import { AuthorizeRequest, MessageTypes, SigningRequest } from '@polkadot/extension/background/types';
import { KeyringJson } from '@polkadot/ui-keyring/types';
import { KeypairType } from '@polkadot/util-crypto/types';
@ -34,15 +34,27 @@ export async function getAccounts (): Promise<Array<KeyringJson>> {
return sendMessage('accounts.list');
}
export async function getRequests (): Promise<Array<[number, MessageExtrinsicSign, string]>> {
export async function getAuthRequests (): Promise<Array<AuthorizeRequest>> {
return sendMessage('authorize.requests');
}
export async function rejectAuthRequest (id: number): Promise<boolean> {
return sendMessage('authorize.reject', { id });
}
export async function approveAuthRequest (id: number): Promise<boolean> {
return sendMessage('authorize.approve', { id });
}
export async function getSignRequests (): Promise<Array<SigningRequest>> {
return sendMessage('signing.requests');
}
export async function cancelRequest (id: number): Promise<boolean> {
export async function cancelSignRequest (id: number): Promise<boolean> {
return sendMessage('signing.cancel', { id });
}
export async function approveRequest (id: number, password: string): Promise<boolean> {
export async function approveSignRequest (id: number, password: string): Promise<boolean> {
return sendMessage('signing.approve', { id, password });
}

View File

@ -3,10 +3,9 @@
// of the Apache-2.0 license. See the LICENSE file for details.
import React from 'react';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import { unicode } from '../components';
import { Link, unicode } from '../components';
type Props = {
className?: string,

View File

@ -4,7 +4,7 @@
<meta charset="utf-8">
<title>polkadot{.js}</title>
</head>
<body style="box-sizing:border-box; height:580px; margin:0; padding:0 0.75rem; width:480px;">
<body style="background:white; box-sizing:border-box; height:580px; margin:0; padding:0 0.75rem; width:480px;">
<div id="root"></div>
<script src='./popup.js'></script>
</body>

View File

@ -8,7 +8,8 @@ import { ExtrinsicEra, Hash, IndexCompact, Struct, U8a } from '@polkadot/types';
import { blake2AsU8a } from '@polkadot/util-crypto';
// This is a copy of the signing in @polkadot/types with the method as a pure U8a
// (without metadata, we cannot reliably parse the Method, hence faling)
// (without metadata, we cannot reliably parse the Method when we don not have
// the actual chain metadata - this is for offline, non-connected signing)
export default class SignaturePayloadRaw extends Struct {
protected _signature?: Uint8Array;
@ -21,52 +22,6 @@ export default class SignaturePayloadRaw extends Struct {
}, value);
}
/**
* @description `true` if the payload refers to a valid signature
*/
get isSigned (): boolean {
return !!(this._signature && this._signature.length === 64);
}
/**
* @description The block [[Hash]] the signature applies to (mortal/immortal)
*/
get blockHash (): Hash {
return this.get('blockHash') as Hash;
}
/**
* @description The [[Method]] contained in the payload
*/
get method (): U8a {
return this.get('method') as U8a;
}
/**
* @description The [[ExtrinsicEra]]
*/
get era (): ExtrinsicEra {
return this.get('era') as ExtrinsicEra;
}
/**
* @description The [[IndexCompact]]
*/
get nonce (): IndexCompact {
return this.get('nonce') as IndexCompact;
}
/**
* @description The raw signature as a `Uint8Array`
*/
get signature (): Uint8Array {
if (!this.isSigned) {
throw new Error('Transaction is not signed');
}
return this._signature as Uint8Array;
}
/**
* @description Sign the payload with the keypair
*/

View File

@ -3,7 +3,7 @@
// of the Apache-2.0 license. See the LICENSE file for details.
import { KeyringJson } from '@polkadot/ui-keyring/types';
import { MessageTypes, MessageAccountCreate, MessageAccountEdit, MessageExtrinsicSignApprove, MessageExtrinsicSignCancel, MessageSeedCreate, MessageSeedCreate$Response, MessageSeedValidate, MessageSeedValidate$Response, MessageAccountForget, SigningRequest } from '../types';
import { AuthorizeRequest, MessageTypes, MessageAccountCreate, MessageAccountEdit, MessageAuthorizeApprove, MessageAuthorizeReject, MessageExtrinsicSignApprove, MessageExtrinsicSignCancel, MessageSeedCreate, MessageSeedCreate$Response, MessageSeedValidate, MessageSeedValidate$Response, MessageAccountForget, SigningRequest } from '../types';
import keyring from '@polkadot/ui-keyring';
import accountsObservable from '@polkadot/ui-keyring/observable/accounts';
@ -11,16 +11,16 @@ import { mnemonicGenerate, mnemonicValidate } from '@polkadot/util-crypto';
import { assert, u8aToHex } from '@polkadot/util';
import RawPayload from '../RawPayload';
import SigningRequests from './SigningRequests';
import State from './State';
const SEED_DEFAULT_LENGTH = 12;
const SEED_LENGTHS = [12, 24];
export default class Extension {
private signing: SigningRequests;
private _state: State;
constructor (signing: SigningRequests) {
this.signing = signing;
constructor (state: State) {
this._state = state;
}
private accountsCreate ({ name, password, suri, type }: MessageAccountCreate): boolean {
@ -51,6 +51,34 @@ export default class Extension {
.map(({ json }) => json);
}
private authorizeApprove ({ id }: MessageAuthorizeApprove): boolean {
const queued = this._state.getAuthRequest(id);
assert(queued, 'Unable to find request');
const { resolve } = queued;
resolve(true);
return true;
}
private authorizeReject ({ id }: MessageAuthorizeReject): boolean {
const queued = this._state.getAuthRequest(id);
assert(queued, 'Unable to find request');
const { reject } = queued;
reject(new Error('Rejected'));
return true;
}
private authorizeRequests (): Array<AuthorizeRequest> {
return this._state.allAuthRequests;
}
private seedCreate ({ length = SEED_DEFAULT_LENGTH, type }: MessageSeedCreate): MessageSeedCreate$Response {
const seed = mnemonicGenerate(length);
@ -71,7 +99,7 @@ export default class Extension {
}
private signingApprove ({ id, password }: MessageExtrinsicSignApprove): boolean {
const queued = this.signing.get(id);
const queued = this._state.getSignRequest(id);
assert(queued, 'Unable to find request');
@ -100,7 +128,7 @@ export default class Extension {
}
private signingCancel ({ id }: MessageExtrinsicSignCancel): boolean {
const queued = this.signing.get(id);
const queued = this._state.getSignRequest(id);
assert(queued, 'Unable to find request');
@ -112,11 +140,20 @@ export default class Extension {
}
private signingRequests (): Array<SigningRequest> {
return this.signing.requests;
return this._state.allSignRequests;
}
async handle (type: MessageTypes, request: any): Promise<any> {
switch (type) {
case 'authorize.approve':
return this.authorizeApprove(request);
case 'authorize.reject':
return this.authorizeReject(request);
case 'authorize.requests':
return this.authorizeRequests();
case 'accounts.create':
return this.accountsCreate(request);

View File

@ -1,64 +0,0 @@
// Copyright 2019 @polkadot/extension-bg authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import { MessageExtrinsicSign, MessageExtrinsicSign$Response } from '../types';
type Request = {
id: number,
request: MessageExtrinsicSign,
resolve: (result: MessageExtrinsicSign$Response) => void,
reject: (error: Error) => void,
url: string
};
export default class SigningRequests {
private _requests: { [index: number]: Request } = {};
get hasRequests (): boolean {
return this.numRequests === 0;
}
get numRequests (): number {
return Object.keys(this._requests).length;
}
get requests (): Array<[number, MessageExtrinsicSign, string]> {
return Object
.values(this._requests)
.map(({ id, request, url }) => [id, request, url]);
}
private complete = (id: number, fn: Function) => {
return (result: MessageExtrinsicSign$Response | Error): void => {
delete this._requests[id];
this.updateIcon();
fn(result);
};
}
private updateIcon (): void {
const count = this.numRequests;
chrome.browserAction.setBadgeText({ text: count ? `${this.numRequests}` : '' });
}
get (id: number): Request {
return this._requests[id];
}
queue (id: number, request: MessageExtrinsicSign, url: string): Promise<MessageExtrinsicSign$Response> {
return new Promise((resolve, reject) => {
this._requests[id] = {
id,
request,
resolve: this.complete(id, resolve),
reject: this.complete(id, reject),
url
};
this.updateIcon();
});
}
}

View File

@ -0,0 +1,178 @@
// Copyright 2019 @polkadot/extension-bg authors & contributors
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import { AuthorizeRequest, MessageAuthorize, MessageExtrinsicSign, MessageExtrinsicSign$Response, SigningRequest } from '../types';
import { assert } from '@polkadot/util';
type AuthRequest = {
id: number,
idStr: string,
request: MessageAuthorize,
resolve: (result: boolean) => void,
reject: (error: Error) => void,
url: string
};
type AuthUrls = {
[index: string]: {
count: number,
id: string,
isAllowed: boolean,
origin: string,
url: string
}
};
type SignRequest = {
id: number,
request: MessageExtrinsicSign,
resolve: (result: MessageExtrinsicSign$Response) => void,
reject: (error: Error) => void,
url: string
};
let idCounter = 0;
export default class State {
// at the moment, we are keeping the list in memory - this should be persisted
private _authUrls: AuthUrls = {};
private _authRequests: { [index: number]: AuthRequest } = {};
private _signRequests: { [index: number]: SignRequest } = {};
get hasAuthRequests (): boolean {
return this.numAuthRequests === 0;
}
get hasSignRequests (): boolean {
return this.numSignRequests === 0;
}
get numAuthRequests (): number {
return Object.keys(this._authRequests).length;
}
get numSignRequests (): number {
return Object.keys(this._signRequests).length;
}
get allAuthRequests (): Array<AuthorizeRequest> {
return Object
.values(this._authRequests)
.map(({ id, request, url }) => [id, request, url]);
}
get allSignRequests (): Array<SigningRequest> {
return Object
.values(this._signRequests)
.map(({ id, request, url }) => [id, request, url]);
}
private authComplete = (id: number, fn: Function) => {
return (result: boolean | Error): void => {
const isAllowed = result === true;
const { idStr, request: { origin }, url } = this._authRequests[id];
this._authUrls[this.stripUrl(url)] = {
count: 0,
id: idStr,
isAllowed,
origin,
url
};
delete this._authRequests[id];
this.updateIcon();
fn(result);
};
}
private signComplete = (id: number, fn: Function) => {
return (result: MessageExtrinsicSign$Response | Error): void => {
delete this._signRequests[id];
this.updateIcon();
fn(result);
};
}
private stripUrl (url: string): string {
assert(url && (url.indexOf('http:') === 0 || url.indexOf('http:') === 0), `Invalid url ${url}, expected to start with http: or https:`);
const parts = url.split('/');
return parts[2];
}
private updateIcon (): void {
const authCount = this.numAuthRequests;
const signCount = this.numSignRequests;
const text = (
authCount
? 'Auth'
: (signCount ? `${signCount}` : '')
);
chrome.browserAction.setBadgeText({ text });
}
async authorizeUrl (url: string, request: MessageAuthorize): Promise<boolean> {
const idStr = this.stripUrl(url);
if (this._authUrls[idStr]) {
assert(this._authUrls[idStr].isAllowed, `The source ${url} is not allowed to interact with this extension`);
return true;
}
return new Promise((resolve, reject) => {
const id = ++idCounter;
this._authRequests[id] = {
id,
idStr,
request,
resolve: this.authComplete(id, resolve),
reject: this.authComplete(id, reject),
url
};
this.updateIcon();
});
}
isUrlAuthorized (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;
}
getAuthRequest (id: number): AuthRequest {
return this._authRequests[id];
}
getSignRequest (id: number): SignRequest {
return this._signRequests[id];
}
signQueue (url: string, request: MessageExtrinsicSign): Promise<MessageExtrinsicSign$Response> {
const id = ++idCounter;
return new Promise((resolve, reject) => {
this._signRequests[id] = {
id,
request,
resolve: this.signComplete(id, resolve),
reject: this.signComplete(id, reject),
url
};
this.updateIcon();
});
}
}

View File

@ -2,24 +2,26 @@
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import { MessageTypes, MessageExtrinsicSign, MessageExtrinsicSign$Response } from '../types';
import { MessageTypes, MessageAuthorize, MessageExtrinsicSign, MessageExtrinsicSign$Response } from '../types';
import keyring from '@polkadot/ui-keyring';
import accountsObservable from '@polkadot/ui-keyring/observable/accounts';
import { assert } from '@polkadot/util';
import SigningRequests from './SigningRequests';
let signCounter = 0;
import State from './State';
export default class Tabs {
private signing: SigningRequests;
private _state: State;
constructor (signing: SigningRequests) {
this.signing = signing;
constructor (state: State) {
this._state = state;
}
private accountsList (): Array<{ address: string, name?: string }> {
private authorize (url: string, request: MessageAuthorize) {
return this._state.authorizeUrl(url, request);
}
private accountsList (url: string): Array<{ address: string, name?: string }> {
return Object
.values(accountsObservable.subject.getValue())
.map(({ json: { address, meta: { name } } }) => ({
@ -27,23 +29,25 @@ export default class Tabs {
}));
}
private extrinsicSign (request: MessageExtrinsicSign, url: string): Promise<MessageExtrinsicSign$Response> {
private extrinsicSign (url: string, request: MessageExtrinsicSign): Promise<MessageExtrinsicSign$Response> {
const { address } = request;
const id = ++signCounter;
const pair = keyring.getPair(address);
assert(pair, 'Unable to find keypair');
return this.signing.queue(id, request, url);
return this._state.signQueue(url, request);
}
async handle (type: MessageTypes, request: any, url: string = 'unknown'): Promise<any> {
switch (type) {
case 'authorize.tab':
return this.authorize(url, request);
case 'accounts.list':
return this.accountsList();
return this.accountsList(url);
case 'extrinsic.sign':
return this.extrinsicSign(request, url);
return this.extrinsicSign(url, request);
default:
throw new Error(`Unable to handle message of type ${type}`);

View File

@ -5,12 +5,12 @@
import { MessageRequest } from '../types';
import Extension from './Extension';
import SigningRequests from './SigningRequests';
import State from './State';
import Tabs from './Tabs';
const signing = new SigningRequests();
const extension = new Extension(signing);
const tabs = new Tabs(signing);
const state = new State();
const extension = new Extension(state);
const tabs = new Tabs(state);
export default function handler ({ id, message, request }: MessageRequest, { tab }: chrome.runtime.MessageSender, sendResponse: (response?: any) => void): boolean {
const source = `${tab ? tab.url : 'extension'}: ${id}: ${message}`;

View File

@ -4,10 +4,24 @@
import { KeypairType } from '@polkadot/util-crypto/types';
export type MessageTypes = 'accounts.create' | 'accounts.edit' | 'accounts.forget' | 'accounts.list' | 'extrinsic.sign' | 'seed.create' | 'seed.validate' | 'signing.approve' | 'signing.cancel' | 'signing.requests';
export type MessageTypes = 'authorize.approve' | 'authorize.reject' | 'authorize.requests' | 'authorize.tab' | 'accounts.create' | 'accounts.edit' | 'accounts.forget' | 'accounts.list' | 'extrinsic.sign' | 'seed.create' | 'seed.validate' | 'signing.approve' | 'signing.cancel' | 'signing.requests';
export type AuthorizeRequest = [number, MessageAuthorize, string];
export type SigningRequest = [number, MessageExtrinsicSign, string];
export type MessageAuthorize = {
origin: string
};
export type MessageAuthorizeApprove = {
id: number;
};
export type MessageAuthorizeReject = {
id: number;
};
export type MessageRequest = {
id: number,
message: MessageTypes,

View File

@ -2,7 +2,7 @@
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import { Injected as IInjected, SendRequest, Version } from './types';
import { Injected as IInjected, SendRequest } from './types';
import Accounts from './Accounts';
import Signer from './Signer';
@ -15,8 +15,4 @@ export default class Injected implements IInjected {
this.accounts = new Accounts(sendRequest);
this.signer = new Signer(sendRequest);
}
get version (): Version {
return 0;
}
}

View File

@ -2,14 +2,43 @@
// This software may be modified and distributed under the terms
// of the Apache-2.0 license. See the LICENSE file for details.
import { MessageTypes } from '../background/types';
import { WindowInjected } from './types';
import events from '../events';
import Injected from './Injected';
const callbacks: { [index: number]: { resolve: (data: any) => void, reject: (error: Error) => void } } = {};
// when sending a message from the injector to the expension, we
// - create an event - this we send to the loader
// - the loader takes this evend and uses sendMessage to the extension background
// - on respoinse, the loader creates a reponse event
// - this injector, reads listends on the events, maps it to the original
// - resolves/rejects the promise with the result
type Callbacks = {
[index: number]: {
resolve: (data: any) => void,
reject: (error: Error) => void
}
};
const callbacks: Callbacks = {};
let idCounter = 0;
// a generic message sender that creates an event, returning a promise that will resolve once
// the event is resolved (by the response listener just below this)
function sendMessage (message: MessageTypes, request: any = null): Promise<any> {
return new Promise((resolve, reject) => {
const id = ++idCounter;
callbacks[id] = { resolve, reject };
document.dispatchEvent(
new CustomEvent(events.request, { detail: { id, message, request } })
);
});
}
// setup a response listener (events created by the loader for extension responses)
document.addEventListener(events.response, (event) => {
const response = (event as CustomEvent).detail;
@ -29,17 +58,13 @@ document.addEventListener(events.response, (event) => {
}
});
// don't clobber the existing object, we will add it it (or create as needed)
(window as WindowInjected).injectedWeb3 = (window as WindowInjected).injectedWeb3 || {};
(window as WindowInjected).injectedWeb3['polkadot-js'] = new Injected(
(message: string, request: any = null): Promise<any> => {
return new Promise((resolve, reject) => {
const id = ++idCounter;
callbacks[id] = { resolve, reject };
document.dispatchEvent(
new CustomEvent(events.request, { detail: { id, message, request } })
);
});
}
);
// add our enable function
(window as WindowInjected).injectedWeb3['polkadot-js'] = {
name: 'polkadot-js', // process.env.PKG_NAME as string,
version: process.env.PKG_VERSION as string,
enable: (origin: string): Promise<Injected> =>
sendMessage('authorize.tab', { origin }).then(() => new Injected(sendMessage))
};

View File

@ -3,8 +3,7 @@
// of the Apache-2.0 license. See the LICENSE file for details.
import { Signer as SignerInterface } from '@polkadot/api/types';
export type Version = 0;
import { MessageTypes } from '../background/types';
export interface Account {
readonly address: string; // ss-58 encoded address
@ -22,15 +21,18 @@ export interface Signer extends SignerInterface {
export interface Injected {
readonly accounts: Accounts;
readonly signer: Signer;
readonly version: Version;
}
export interface SendRequest {
(message: string, request?: any): Promise<any>;
(message: MessageTypes, request?: any): Promise<any>;
}
export type WindowInjected = Window & {
injectedWeb3: {
[index: string]: Injected
[index: string]: {
name: string,
version: string,
enable (origin: string): Promise<Injected>
}
}
};

View File

@ -79,7 +79,8 @@ function createWebpack ({ alias = {}, context }) {
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: JSON.stringify(ENV),
VERSION: JSON.stringify(pkgJson.version)
PKG_NAME: JSON.stringify(pkgJson.name),
PKG_VERSION: JSON.stringify(pkgJson.version),
}
}),
new CopyWebpackPlugin([{ from: 'public' }])