summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar Jordan Johnson-Doyle <jordan@doyle.la> 2019-03-16 11:05:10 +0000
committerGravatar Jordan Johnson-Doyle <jordan@doyle.la> 2019-03-16 11:05:10 +0000
commitd5fc74cc5baf59a643cf58cdd9d17739212b09c1 (patch)
tree5006927e90272e49d353be7def0d04acd269a0bc
parent04e1288ad764241318ea7eaff6e7d6aad77bd96c (diff)
Give approval for scripts via the popup and allow async script approval by queuing scripts
-rw-r--r--manifest.json3
-rw-r--r--src/common/GetKeybaseUserForDomainEvent.ts3
-rw-r--r--src/common/GetUsersAwaitingConsentEvent.ts33
-rw-r--r--src/common/Script.ts5
-rw-r--r--src/content/KeyRing.ts53
-rw-r--r--src/content/PendingSignerError.ts1
-rw-r--r--src/content/ScriptInterceptor.ts157
-rw-r--r--src/popup/js/pages/Index.tsx70
8 files changed, 251 insertions, 74 deletions
diff --git a/manifest.json b/manifest.json
index 48b633e..768d91e 100644
--- a/manifest.json
+++ b/manifest.json
@@ -13,9 +13,6 @@
"run_at": "document_start"
}
],
- "background": {
- "scripts": ["dist/background.js"]
- },
"browser_action": {
"browser_style": true,
"default_title": "CryptoScript",
diff --git a/src/common/GetKeybaseUserForDomainEvent.ts b/src/common/GetKeybaseUserForDomainEvent.ts
index 9bf6213..2aa79f5 100644
--- a/src/common/GetKeybaseUserForDomainEvent.ts
+++ b/src/common/GetKeybaseUserForDomainEvent.ts
@@ -14,6 +14,7 @@ export class GetKeybaseUserForDomainResponse {
constructor(
public readonly keybaseUsers: KeybaseUser[],
public readonly trusted: KeybaseUser[],
- public readonly barred: KeybaseUser[]
+ public readonly barred: KeybaseUser[],
+ public readonly pending: KeybaseUser[],
) {}
}
diff --git a/src/common/GetUsersAwaitingConsentEvent.ts b/src/common/GetUsersAwaitingConsentEvent.ts
new file mode 100644
index 0000000..925038f
--- /dev/null
+++ b/src/common/GetUsersAwaitingConsentEvent.ts
@@ -0,0 +1,33 @@
+import { IEvent } from "./IEvent";
+
+export class GetUsersAwaitingConsentEvent extends IEvent {
+ public static readonly TYPE = "GET_USERS_AWAITING_CONSENT_EVENT";
+
+ constructor(public readonly domain: string) {
+ super(GetUsersAwaitingConsentEvent.TYPE);
+ }
+}
+
+type KeybaseUser = string;
+
+export class GetUsersAwaitingConsentResponse {
+ constructor(
+ public readonly keybaseUsers: KeybaseUser[]
+ ) {}
+}
+
+export class AllowUserEvent extends IEvent {
+ public static readonly TYPE = "ALLOW_USER_EVENT";
+
+ constructor(public readonly domain: string, public readonly user: KeybaseUser) {
+ super(AllowUserEvent.TYPE);
+ }
+}
+
+export class DeniedUserEvent extends IEvent {
+ public static readonly TYPE = "DENIED_USER_EVENT";
+
+ constructor(public readonly domain: string, public readonly user: KeybaseUser) {
+ super(DeniedUserEvent.TYPE);
+ }
+} \ No newline at end of file
diff --git a/src/common/Script.ts b/src/common/Script.ts
new file mode 100644
index 0000000..7466436
--- /dev/null
+++ b/src/common/Script.ts
@@ -0,0 +1,5 @@
+export interface Script {
+ signer: string;
+ script: string;
+ href: string;
+} \ No newline at end of file
diff --git a/src/content/KeyRing.ts b/src/content/KeyRing.ts
index 7715269..5454f9f 100644
--- a/src/content/KeyRing.ts
+++ b/src/content/KeyRing.ts
@@ -1,5 +1,6 @@
import * as P from "bluebird";
import { Buffer, KeyFetcher, KeyManager } from "kbpgp";
+import { PendingSignerError } from "./PendingSignerError";
const importFromArmoredPgp = P.promisify<KeyManager, { armored: string }>(KeyManager.import_from_armored_pgp, {
context: KeyManager
@@ -16,6 +17,7 @@ export default class KeyRing extends KeyFetcher {
private trustedUsers: string[] = [];
private barredUsers: string[] = [];
+ private pendingApproval: string[] = [];
constructor(private domain: string) {
super();
@@ -47,6 +49,13 @@ export default class KeyRing extends KeyFetcher {
return this.trustedUsers;
}
+ public addTrustedUser(user: string) {
+ this.pendingApproval = this.pendingApproval.filter(v => v !== user);
+ this.barredUsers = this.barredUsers.filter(v => v !== user);
+ this.trustedUsers.push(user);
+ this.storeUsersInStorage();
+ }
+
/**
* Get users that have been previously been barred from running scripts
* on the `domain` that was used to instantiate this KeyRing.
@@ -57,6 +66,19 @@ export default class KeyRing extends KeyFetcher {
return this.barredUsers;
}
+ public addBarredUser(user: string) {
+ this.pendingApproval = this.pendingApproval.filter(v => v !== user);
+ this.trustedUsers = this.trustedUsers.filter(v => v !== user);
+ this.barredUsers.push(user);
+ this.storeUsersInStorage();
+ }
+
+ public async getPendingApproval(): Promise<string[]> {
+ await this.populateKeysForDomain();
+
+ return this.pendingApproval;
+ }
+
/**
* Ran by KBPGP to match a key id to a public key. Throws an error if
* the signer isn't a "trusted" Keybase user
@@ -86,6 +108,12 @@ export default class KeyRing extends KeyFetcher {
k.keyManager,
i
);
+ } else if (this.pendingApproval.includes(k.keybaseUser)) {
+ return cb(
+ new PendingSignerError(`Keybase user ${k.keybaseUser} is not yet approved for script signing on ${this.domain}`),
+ k.keyManager,
+ i
+ );
} else if (this.trustedUsers.includes(k.keybaseUser) && k.key.key.can_perform(ops)) {
console.debug(`Allowing script from ${this.domain} to run as it was signed by ${k.keybaseUser}`);
return k.keyManager.fetch(ids, ops, cb);
@@ -115,11 +143,7 @@ export default class KeyRing extends KeyFetcher {
const username = user.basics.username;
if (!this.hasPreviouslySeenKeybaseUser(username)) {
- if (this.getTreatmentForKeybaseUser(username)) {
- this.trustedUsers.push(username);
- } else {
- this.barredUsers.push(username);
- }
+ this.pendingApproval.push(username);
}
// @ts-ignore
@@ -193,23 +217,4 @@ export default class KeyRing extends KeyFetcher {
private hasPreviouslySeenKeybaseUser(user: string): boolean {
return [...this.trustedUsers, ...this.barredUsers].includes(user);
}
-
- /**
- * Ask the user whether or not they'd like to run scripts from the given Keybase user.
- *
- * @param keybaseUser keybaseUser to ask permission for
- */
- private getTreatmentForKeybaseUser(keybaseUser: string): boolean {
- if (
- window.confirm(
- `Since you last ran JavaScript from the domain ${
- this.domain
- }, ${keybaseUser} has claimed ownership on Keybase. Would you like to allow JavaScript from this domain to be signed by this user?`
- )
- ) {
- return true;
- } else {
- return false;
- }
- }
}
diff --git a/src/content/PendingSignerError.ts b/src/content/PendingSignerError.ts
new file mode 100644
index 0000000..ddf99be
--- /dev/null
+++ b/src/content/PendingSignerError.ts
@@ -0,0 +1 @@
+export class PendingSignerError extends Error {} \ No newline at end of file
diff --git a/src/content/ScriptInterceptor.ts b/src/content/ScriptInterceptor.ts
index bba5d0d..4614f5c 100644
--- a/src/content/ScriptInterceptor.ts
+++ b/src/content/ScriptInterceptor.ts
@@ -5,27 +5,42 @@ import { GetKeybaseUserForDomainEvent, GetKeybaseUserForDomainResponse } from ".
import { IEvent } from "../common/IEvent";
import KeyRing from "./KeyRing";
import { fetch } from "./util";
+import { GetUsersAwaitingConsentEvent, GetUsersAwaitingConsentResponse, AllowUserEvent, DeniedUserEvent } from "../common/GetUsersAwaitingConsentEvent";
+import { Script } from "../common/Script";
+import { PendingSignerError } from "./PendingSignerError";
const unbox = P.promisify<any, any>(unboxSync);
export default new class ScriptInterceptor implements EventListenerObject {
private keyRingCache: { [domain: string]: KeyRing } = {};
+ private lock: boolean = false;
+ private scriptQueue: HTMLScriptElement[] = [];
+
constructor() {
browser.runtime.onMessage.addListener(async (message: IEvent) => {
if (message.TYPE === GetKeybaseUserForDomainEvent.TYPE) {
const event = message as GetKeybaseUserForDomainEvent;
- const keyRing = this.keyRingCache[event.domain];
-
- if (keyRing) {
- return new GetKeybaseUserForDomainResponse(
- await keyRing.getKeybaseUsers(),
- await keyRing.getTrustedUsers(),
- await keyRing.getBarredUsers()
- );
- } else {
- return new GetKeybaseUserForDomainResponse([], [], []);
- }
+ const keyRing = this.getKeyRingForDomain(event.domain);
+
+ return new GetKeybaseUserForDomainResponse(
+ await keyRing.getKeybaseUsers(),
+ await keyRing.getTrustedUsers(),
+ await keyRing.getBarredUsers(),
+ await keyRing.getPendingApproval()
+ );
+ } else if (message.TYPE === GetUsersAwaitingConsentEvent.TYPE) {
+ const event = message as GetUsersAwaitingConsentEvent;
+ const keyRing = this.getKeyRingForDomain(event.domain);
+ return new GetUsersAwaitingConsentResponse(await keyRing.getPendingApproval());
+ } else if (message.TYPE === AllowUserEvent.TYPE) {
+ const event = message as AllowUserEvent;
+ this.getKeyRingForDomain(event.domain).addTrustedUser(event.user);
+ await this.drainScriptQueue();
+ } else if (message.TYPE === DeniedUserEvent.TYPE) {
+ const event = message as DeniedUserEvent;
+ this.getKeyRingForDomain(event.domain).addBarredUser(event.user);
+ await this.drainScriptQueue();
}
});
}
@@ -44,18 +59,98 @@ export default new class ScriptInterceptor implements EventListenerObject {
// the script tag that we stopped from running
const script = e.target as HTMLScriptElement;
- try {
- const scriptContent = await this.getScriptContent(script);
+ const monitor = setInterval(async () => {
+ if (this.lock) return;
+
+ try {
+ this.lock = true;
- const domain = new URL((window as any).location.href).hostname;
+ await this.checkPermissionMaybeExecute(script);
+ } catch (e) {
+ console.error(`Execution of script (${script.src || "inline"}) failed. ${e.name}: ${e.message}`, e.stack);
+ throw e;
+ } finally {
+ this.lock = false;
+ clearInterval(monitor);
+ }
+ }, 10);
+ }
+
+ private async checkPermissionMaybeExecute(script: HTMLScriptElement) {
+ const scriptContent = await this.getScriptContent(script);
+
+ const domain = new URL((window as any).location.href).hostname;
+
+ if (this.scriptQueue.length > 0) {
+ this.scriptQueue.push(script);
+ script.parentNode.removeChild(script);
+ return;
+ }
+ try {
if (await this.verifySignature(script, scriptContent, domain)) {
// in firefox calling eval on "window" executes in the context of the page thankfully
(window as any).eval(scriptContent);
+ } else {
+ script.parentNode.removeChild(script);
}
} catch (e) {
- console.error(`Execution of script (${script.src || "inline"}) failed. ${e.name}: ${e.message}`, e.stack);
- throw e;
+ if (e instanceof PendingSignerError) {
+ this.scriptQueue.push(script);
+ script.parentNode.removeChild(script);
+ } else {
+ throw e;
+ }
+ }
+ }
+
+ private async drainScriptQueue() {
+ const domain = new URL((window as any).location.href).hostname;
+
+ while (this.scriptQueue.length > 0) {
+ const script = this.scriptQueue.shift();
+
+ try {
+ const scriptContent = await this.getScriptContent(script);
+
+ if (await this.verifySignature(script, scriptContent, domain)) {
+ (window as any).eval(scriptContent);
+ } else {
+ console.log(`Script depends on a blocked signer, there's still ${this.scriptQueue.length} elements left in the script queue.`);
+ this.scriptQueue.unshift(script);
+ return;
+ }
+ } catch (e) {
+ if (e instanceof PendingSignerError) {
+ this.scriptQueue.unshift(script);
+ console.log(`Script depends on a pending signer, there's still ${this.scriptQueue.length} elements left in the script queue.`);
+ return;
+ } else {
+ this.scriptQueue.unshift(script);
+ throw e;
+ }
+ }
+ }
+
+ for (const script of this.scriptQueue) {
+ try {
+ const scriptContent = await this.getScriptContent(script);
+
+ if (await this.verifySignature(script, scriptContent, domain)) {
+ (window as any).eval(scriptContent);
+ this.scriptQueue.shift();
+ } else {
+ console.log(`Script depends on a blocked signer, there's still ${this.scriptQueue.length} elements left in the script queue.`);
+ return;
+ }
+ } catch (e) {
+ if (e instanceof PendingSignerError) {
+ console.log(`Script depends on a pending signer, there's still ${this.scriptQueue.length} elements left in the script queue.`);
+ return;
+ } else {
+ throw e;
+ }
+ }
}
}
@@ -82,6 +177,12 @@ export default new class ScriptInterceptor implements EventListenerObject {
* @throws Error if signature verification fails
*/
private async verifySignature(script: HTMLScriptElement, scriptContent: string, domain: string): Promise<boolean> {
+ const signaturePath = script.dataset.signature;
+
+ if (!signaturePath) {
+ return await this.getTreatmentForUnsignedScripts(domain);
+ }
+
const keyRing = this.getKeyRingForDomain(domain);
if (!(await keyRing.getKeybaseUsers()).length) {
@@ -90,7 +191,7 @@ export default new class ScriptInterceptor implements EventListenerObject {
return true;
}
- const signatureContent = await this.getSignatureFromScript(script);
+ const signatureContent = await (await fetch(signaturePath)).text();
const literals = await unbox({
armored: new KbpgpBuffer(signatureContent),
@@ -125,19 +226,19 @@ export default new class ScriptInterceptor implements EventListenerObject {
}
/**
- * Get signature of the script from the `data-signature` attribute.
- *
- * @param script script to get signature of
- * @throws Error if script is not signed
+ * Ask the user whether or not they'd like to run unsigned scripts.
*/
- private async getSignatureFromScript(script: HTMLScriptElement): Promise<string> {
- const signaturePath = script.dataset.signature;
+ private async getTreatmentForUnsignedScripts(domain: string): Promise<boolean> {
+ const storageKey = `kpj_unsigned_scripts_${domain}`;
- if (!signaturePath) {
- throw new Error(`Script not signed (no data-signature attribute)`);
- }
+ const storedAnswer = (await browser.storage.local.get(storageKey))[storageKey];
- const signatureRequest = await Promise.resolve(fetch(signaturePath));
- return await signatureRequest.text();
+ if (storedAnswer !== undefined) {
+ return !!storedAnswer;
+ } else {
+ const confirmation = !!window.confirm('There are unsigned scripts on this website, would you like to run them?');
+ await browser.storage.local.set({ [storageKey]: confirmation });
+ return confirmation;
+ }
}
}();
diff --git a/src/popup/js/pages/Index.tsx b/src/popup/js/pages/Index.tsx
index c0da741..fa874cc 100644
--- a/src/popup/js/pages/Index.tsx
+++ b/src/popup/js/pages/Index.tsx
@@ -2,6 +2,7 @@ import * as React from 'react';
import { GetKeybaseUserForDomainEvent, GetKeybaseUserForDomainResponse } from "../../../common/GetKeybaseUserForDomainEvent";
import { Row } from "../components/Row";
+import { GetUsersAwaitingConsentEvent, GetUsersAwaitingConsentResponse, AllowUserEvent, DeniedUserEvent } from '../../../common/GetUsersAwaitingConsentEvent';
interface ToolbarProps {
}
@@ -10,21 +11,10 @@ interface ToolbarState {
domain: string,
keybaseUsers: string[],
trustedUsers: string[],
- barredUsers: string[]
+ barredUsers: string[],
+ needsApproval: string[]
}
-const WriteUsersList = (props: { header: string, users: string[] }) => {
- if (props.users.length) {
- return <Row header={ props.header }>
- <ul>
- { props.users.map((u) => <li>{ u }</li>) }
- </ul>
- </Row>;
- } else {
- return <div />;
- }
-};
-
export class Index extends React.Component<ToolbarProps, ToolbarState> {
constructor(props: any) {
super(props);
@@ -32,7 +22,8 @@ export class Index extends React.Component<ToolbarProps, ToolbarState> {
domain: "",
keybaseUsers: [],
trustedUsers: [],
- barredUsers: []
+ barredUsers: [],
+ needsApproval: []
};
}
@@ -44,20 +35,63 @@ export class Index extends React.Component<ToolbarProps, ToolbarState> {
this.setState({ domain });
- await browser.tabs.sendMessage(activeTab.id, new GetKeybaseUserForDomainEvent(domain))
+ await this.updateUsers();
+ }
+
+ async updateUsers() {
+ const [ activeTab ] = await browser.tabs.query({ active: true });
+
+ await browser.tabs.sendMessage(activeTab.id, new GetKeybaseUserForDomainEvent(this.state.domain))
.then((res: GetKeybaseUserForDomainResponse) => {
this.setState({
keybaseUsers: res.keybaseUsers,
trustedUsers: res.trusted,
- barredUsers: res.barred
+ barredUsers: res.barred,
+ needsApproval: res.pending
});
});
}
+ approve = async (user: string) => {
+ const [ activeTab ] = await browser.tabs.query({ active: true });
+ browser.tabs.sendMessage(activeTab.id, new AllowUserEvent(this.state.domain, user))
+ .then(this.updateUsers.bind(this));
+ }
+
+ deny = async (user: string) => {
+ const [ activeTab ] = await browser.tabs.query({ active: true });
+ browser.tabs.sendMessage(activeTab.id, new DeniedUserEvent(this.state.domain, user))
+ .then(this.updateUsers.bind(this));
+ }
+
render() {
return <div className="container">
- <WriteUsersList header="Trusted Keybase Users" users={ this.state.trustedUsers } />
- <WriteUsersList header="Barred Keybase Users" users={ this.state.barredUsers } />
+ <Row header="Trusted Keybase Users">
+ <ul>
+ { this.state.trustedUsers.map((u) => <li>
+ { u }&nbsp;
+ <a href="#" onClick={ e => this.deny(u) } style={{ color: '#F44336' }}>deny</a>
+ </li>) }
+ </ul>
+ </Row>
+ <Row header="Barred Keybase Users">
+ <ul>
+ { this.state.barredUsers.map((u) => <li>
+ { u }&nbsp;
+ <a href="#" onClick={ e => this.approve(u) } style={{ color: '#4CAF50' }}>approve</a>
+ </li>) }
+ </ul>
+ </Row>
+ <Row header="Needs Approval">
+ <ul>
+ { this.state.needsApproval.map((u) => <li>
+ { u }&nbsp;
+ <a href="#" onClick={ e => this.approve(u) } style={{ color: '#4CAF50' }}>approve</a>&nbsp;
+ <a href="#" onClick={ e => this.deny(u) } style={{ color: '#F44336' }}>deny</a>
+ </li>) }
+ </ul>
+ </Row>
+
</div>;
}
}