diff options
Diffstat (limited to 'lib/wallet')
-rw-r--r-- | lib/wallet/checkable.ts | 262 | ||||
-rw-r--r-- | lib/wallet/chromeBadge.ts | 227 | ||||
-rw-r--r-- | lib/wallet/cryptoApi.ts | 204 | ||||
-rw-r--r-- | lib/wallet/cryptoLib.ts | 227 | ||||
-rw-r--r-- | lib/wallet/cryptoWorker.ts | 61 | ||||
-rw-r--r-- | lib/wallet/db.ts | 119 | ||||
-rw-r--r-- | lib/wallet/emscriptif.ts | 1033 | ||||
-rw-r--r-- | lib/wallet/helpers.ts | 67 | ||||
-rw-r--r-- | lib/wallet/http.ts | 84 | ||||
-rw-r--r-- | lib/wallet/query.ts | 415 | ||||
-rw-r--r-- | lib/wallet/renderHtml.ts | 49 | ||||
-rw-r--r-- | lib/wallet/types.ts | 396 | ||||
-rw-r--r-- | lib/wallet/wallet.ts | 1156 | ||||
-rw-r--r-- | lib/wallet/wxApi.ts | 41 | ||||
-rw-r--r-- | lib/wallet/wxMessaging.ts | 392 |
15 files changed, 4733 insertions, 0 deletions
diff --git a/lib/wallet/checkable.ts b/lib/wallet/checkable.ts new file mode 100644 index 000000000..9fd816578 --- /dev/null +++ b/lib/wallet/checkable.ts @@ -0,0 +1,262 @@ +/* + This file is part of TALER + (C) 2016 GNUnet e.V. + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + + +"use strict"; + +/** + * Decorators for type-checking JSON into + * an object. + * @module Checkable + * @author Florian Dold + */ + +export namespace Checkable { + + type Path = (number|string)[]; + + interface SchemaErrorConstructor { + new (err: string): SchemaError; + } + + interface SchemaError { + name: string; + message: string; + } + + interface Prop { + propertyKey: any; + checker: any; + type: any; + elementChecker?: any; + elementProp?: any; + } + + export let SchemaError = (function SchemaError(message: string) { + this.name = 'SchemaError'; + this.message = message; + this.stack = (<any>new Error()).stack; + }) as any as SchemaErrorConstructor; + + + SchemaError.prototype = new Error; + + let chkSym = Symbol("checkable"); + + + function checkNumber(target: any, prop: Prop, path: Path): any { + if ((typeof target) !== "number") { + throw new SchemaError(`expected number for ${path}`); + } + return target; + } + + + function checkString(target: any, prop: Prop, path: Path): any { + if (typeof target !== "string") { + throw new SchemaError(`expected string for ${path}, got ${typeof target} instead`); + } + return target; + } + + + function checkAnyObject(target: any, prop: Prop, path: Path): any { + if (typeof target !== "object") { + throw new SchemaError(`expected (any) object for ${path}, got ${typeof target} instead`); + } + return target; + } + + + function checkAny(target: any, prop: Prop, path: Path): any { + return target; + } + + + function checkList(target: any, prop: Prop, path: Path): any { + if (!Array.isArray(target)) { + throw new SchemaError(`array expected for ${path}, got ${typeof target} instead`); + } + for (let i = 0; i < target.length; i++) { + let v = target[i]; + prop.elementChecker(v, prop.elementProp, path.concat([i])); + } + return target; + } + + + function checkOptional(target: any, prop: Prop, path: Path): any { + console.assert(prop.propertyKey); + prop.elementChecker(target, + prop.elementProp, + path.concat([prop.propertyKey])); + return target; + } + + + function checkValue(target: any, prop: Prop, path: Path): any { + let type = prop.type; + if (!type) { + throw Error(`assertion failed (prop is ${JSON.stringify(prop)})`); + } + let v = target; + if (!v || typeof v !== "object") { + throw new SchemaError( + `expected object for ${path.join(".")}, got ${typeof v} instead`); + } + let props = type.prototype[chkSym].props; + let remainingPropNames = new Set(Object.getOwnPropertyNames(v)); + let obj = new type(); + for (let prop of props) { + if (!remainingPropNames.has(prop.propertyKey)) { + if (prop.optional) { + continue; + } + throw new SchemaError("Property missing: " + prop.propertyKey); + } + if (!remainingPropNames.delete(prop.propertyKey)) { + throw new SchemaError("assertion failed"); + } + let propVal = v[prop.propertyKey]; + obj[prop.propertyKey] = prop.checker(propVal, + prop, + path.concat([prop.propertyKey])); + } + + if (remainingPropNames.size != 0) { + throw new SchemaError("superfluous properties " + JSON.stringify(Array.from( + remainingPropNames.values()))); + } + return obj; + } + + + export function Class(target: any) { + target.checked = (v: any) => { + return checkValue(v, { + propertyKey: "(root)", + type: target, + checker: checkValue + }, ["(root)"]); + }; + return target; + } + + + export function Value(type: any) { + if (!type) { + throw Error("Type does not exist yet (wrong order of definitions?)"); + } + function deco(target: Object, propertyKey: string | symbol): void { + let chk = mkChk(target); + chk.props.push({ + propertyKey: propertyKey, + checker: checkValue, + type: type + }); + } + + return deco; + } + + + export function List(type: any) { + let stub = {}; + type(stub, "(list-element)"); + let elementProp = mkChk(stub).props[0]; + let elementChecker = elementProp.checker; + if (!elementChecker) { + throw Error("assertion failed"); + } + function deco(target: Object, propertyKey: string | symbol): void { + let chk = mkChk(target); + chk.props.push({ + elementChecker, + elementProp, + propertyKey: propertyKey, + checker: checkList, + }); + } + + return deco; + } + + + export function Optional(type: any) { + let stub = {}; + type(stub, "(optional-element)"); + let elementProp = mkChk(stub).props[0]; + let elementChecker = elementProp.checker; + if (!elementChecker) { + throw Error("assertion failed"); + } + function deco(target: Object, propertyKey: string | symbol): void { + let chk = mkChk(target); + chk.props.push({ + elementChecker, + elementProp, + propertyKey: propertyKey, + checker: checkOptional, + optional: true, + }); + } + + return deco; + } + + + export function Number(target: Object, propertyKey: string | symbol): void { + let chk = mkChk(target); + chk.props.push({propertyKey: propertyKey, checker: checkNumber}); + } + + + export function AnyObject(target: Object, + propertyKey: string | symbol): void { + let chk = mkChk(target); + chk.props.push({ + propertyKey: propertyKey, + checker: checkAnyObject + }); + } + + + export function Any(target: Object, + propertyKey: string | symbol): void { + let chk = mkChk(target); + chk.props.push({ + propertyKey: propertyKey, + checker: checkAny, + optional: true + }); + } + + + export function String(target: Object, propertyKey: string | symbol): void { + let chk = mkChk(target); + chk.props.push({propertyKey: propertyKey, checker: checkString}); + } + + + function mkChk(target: any) { + let chk = target[chkSym]; + if (!chk) { + chk = {props: []}; + target[chkSym] = chk; + } + return chk; + } +}
\ No newline at end of file diff --git a/lib/wallet/chromeBadge.ts b/lib/wallet/chromeBadge.ts new file mode 100644 index 000000000..df12fba83 --- /dev/null +++ b/lib/wallet/chromeBadge.ts @@ -0,0 +1,227 @@ +/* + This file is part of TALER + (C) 2016 INRIA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + Badge +} from "./wallet"; + + +/** + * Polyfill for requestAnimationFrame, which + * doesn't work from a background page. + */ +function rAF(cb: (ts: number) => void) { + window.setTimeout(() => { + cb(performance.now()); + }, 100 /* 100 ms delay between frames */); +} + + +export class ChromeBadge implements Badge { + canvas: HTMLCanvasElement; + ctx: CanvasRenderingContext2D; + /** + * True if animation running. The animation + * might still be running even if we're not busy anymore, + * just to transition to the "normal" state in a animated way. + */ + animationRunning: boolean = false; + + /** + * Is the wallet still busy? Note that we do not stop the + * animation immediately when the wallet goes idle, but + * instead slowly close the gap. + */ + isBusy: boolean = false; + + /** + * Current rotation angle, ranges from 0 to rotationAngleMax. + */ + rotationAngle: number = 0; + + /** + * While animating, how wide is the current gap in the circle? + * Ranges from 0 to openMax. + */ + gapWidth: number = 0; + + /** + * Maximum value for our rotationAngle, corresponds to 2 Pi. + */ + static rotationAngleMax = 1000; + + /** + * How fast do we rotate? Given in rotation angle (relative to rotationAngleMax) per millisecond. + */ + static rotationSpeed = 0.5; + + /** + * How fast to we open? Given in rotation angle (relative to rotationAngleMax) per millisecond. + */ + static openSpeed = 0.15; + + /** + * How fast to we close? Given as a multiplication factor per frame update. + */ + static closeSpeed = 0.7; + + /** + * How far do we open? Given relative to rotationAngleMax. + */ + static openMax = 100; + + constructor(window?: Window) { + // Allow injecting another window for testing + let bg = window || chrome.extension.getBackgroundPage(); + if (!bg) { + throw Error("no window available"); + } + this.canvas = bg.document.createElement("canvas"); + // Note: changing the width here means changing the font + // size in draw() as well! + this.canvas.width = 32; + this.canvas.height = 32; + this.ctx = this.canvas.getContext("2d")!; + this.draw(); + } + + /** + * Draw the badge based on the current state. + */ + private draw() { + this.ctx.setTransform(1, 0, 0, 1, 0, 0); + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + + this.ctx.translate(this.canvas.width / 2, this.canvas.height / 2); + + this.ctx.beginPath(); + this.ctx.arc(0, 0, this.canvas.width / 2 - 2, 0, 2 * Math.PI); + this.ctx.fillStyle = "white"; + this.ctx.fill(); + + // move into the center, off by 2 for aligning the "T" with the bottom + // of the circle. + this.ctx.translate(0, 2); + + // pick sans-serif font; note: 14px is based on the 32px width above! + this.ctx.font = "bold 24px sans-serif"; + // draw the "T" perfectly centered (x and y) to the current position + this.ctx.textAlign = "center"; + this.ctx.textBaseline = "middle"; + this.ctx.fillStyle = "black"; + this.ctx.fillText("T", 0, 0); + // now move really into the center + this.ctx.translate(0, -2); + // start drawing the (possibly open) circle + this.ctx.beginPath(); + this.ctx.lineWidth = 2.5; + if (this.animationRunning) { + /* Draw circle around the "T" with an opening of this.gapWidth */ + this.ctx.arc(0, 0, + this.canvas.width / 2 - 2, /* radius */ + this.rotationAngle / ChromeBadge.rotationAngleMax * Math.PI * 2, + ((this.rotationAngle + ChromeBadge.rotationAngleMax - this.gapWidth) / ChromeBadge.rotationAngleMax) * Math.PI * 2, + false); + } + else { + /* Draw full circle */ + this.ctx.arc(0, 0, + this.canvas.width / 2 - 2, /* radius */ + 0, + Math.PI * 2, + false); + } + this.ctx.stroke(); + // go back to the origin + this.ctx.translate(-this.canvas.width / 2, -this.canvas.height / 2); + + // Allow running outside the extension for testing + if (window["chrome"] && window.chrome["browserAction"]) { + let imageData = this.ctx.getImageData(0, + 0, + this.canvas.width, + this.canvas.height); + chrome.browserAction.setIcon({imageData}); + } + } + + private animate() { + if (this.animationRunning) { + return; + } + this.animationRunning = true; + let start: number|undefined = undefined; + let step = (timestamp: number) => { + if (!this.animationRunning) { + return; + } + if (!start) { + start = timestamp; + } + let delta = (timestamp - start); + if (!this.isBusy && 0 == this.gapWidth) { + // stop if we're close enough to origin + this.rotationAngle = 0; + } else { + this.rotationAngle = (this.rotationAngle + (timestamp - start) * ChromeBadge.rotationSpeed) % ChromeBadge.rotationAngleMax; + } + if (this.isBusy) { + if (this.gapWidth < ChromeBadge.openMax) { + this.gapWidth += ChromeBadge.openSpeed * (timestamp - start); + } + if (this.gapWidth > ChromeBadge.openMax) { + this.gapWidth = ChromeBadge.openMax; + } + } + else { + if (this.gapWidth > 0) { + this.gapWidth--; + this.gapWidth *= ChromeBadge.closeSpeed; + } + } + + + if (this.isBusy || this.gapWidth > 0) { + start = timestamp; + rAF(step); + } else { + this.animationRunning = false; + } + this.draw(); + }; + rAF(step); + } + + setText(s: string) { + chrome.browserAction.setBadgeText({text: s}); + } + + setColor(c: string) { + chrome.browserAction.setBadgeBackgroundColor({color: c}); + } + + startBusy() { + if (this.isBusy) { + return; + } + this.isBusy = true; + this.animate(); + } + + stopBusy() { + this.isBusy = false; + } +} diff --git a/lib/wallet/cryptoApi.ts b/lib/wallet/cryptoApi.ts new file mode 100644 index 000000000..db29592fc --- /dev/null +++ b/lib/wallet/cryptoApi.ts @@ -0,0 +1,204 @@ +/* + This file is part of TALER + (C) 2016 GNUnet e.V. + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + + +/** + * API to access the Taler crypto worker thread. + * @author Florian Dold + */ + + +import {PreCoin} from "./types"; +import {Reserve} from "./types"; +import {Denomination} from "./types"; +import {Offer} from "./wallet"; +import {CoinWithDenom} from "./wallet"; +import {PayCoinInfo} from "./types"; + +interface RegistryEntry { + resolve: any; + reject: any; + workerIndex: number; +} + +interface WorkerState { + /** + * The actual worker thread. + */ + w: Worker; + /** + * Are we currently running a task on this worker? + */ + busy: boolean; +} + +interface WorkItem { + operation: string; + args: any[]; + resolve: any; + reject: any; +} + + +/** + * Number of different priorities. Each priority p + * must be 0 <= p < NUM_PRIO. + */ +const NUM_PRIO = 5; + +export class CryptoApi { + private nextRpcId: number = 1; + private rpcRegistry: {[n: number]: RegistryEntry} = {}; + private workers: WorkerState[]; + private workQueues: WorkItem[][]; + /** + * Number of busy workers. + */ + private numBusy: number = 0; + /** + * Number if pending work items. + */ + private numWaiting: number = 0; + + + constructor() { + let handler = (msg: MessageEvent) => { + let id = msg.data.id; + if (typeof id !== "number") { + console.error("rpc id must be number"); + return; + } + if (!this.rpcRegistry[id]) { + console.error(`RPC with id ${id} has no registry entry`); + return; + } + let {resolve, workerIndex} = this.rpcRegistry[id]; + delete this.rpcRegistry[id]; + let ws = this.workers[workerIndex]; + if (!ws.busy) { + throw Error("assertion failed"); + } + ws.busy = false; + this.numBusy--; + resolve(msg.data.result); + + // try to find more work for this worker + for (let i = 0; i < NUM_PRIO; i++) { + let q = this.workQueues[NUM_PRIO - i - 1]; + if (q.length != 0) { + let work: WorkItem = q.shift()!; + let msg: any = { + operation: work.operation, + args: work.args, + id: this.registerRpcId(work.resolve, work.reject, workerIndex), + }; + ws.w.postMessage(msg); + ws.busy = true; + this.numBusy++; + return; + } + } + }; + + this.workers = new Array<WorkerState>((navigator as any)["hardwareConcurrency"] || 2); + + for (let i = 0; i < this.workers.length; i++) { + let w = new Worker("/lib/wallet/cryptoWorker.js"); + w.onmessage = handler; + this.workers[i] = { + w, + busy: false, + }; + } + this.workQueues = []; + for (let i = 0; i < NUM_PRIO; i++) { + this.workQueues.push([]); + } + } + + + private registerRpcId(resolve: any, reject: any, + workerIndex: number): number { + let id = this.nextRpcId++; + this.rpcRegistry[id] = {resolve, reject, workerIndex}; + return id; + } + + + private doRpc<T>(operation: string, priority: number, + ...args: any[]): Promise<T> { + if (this.numBusy == this.workers.length) { + let q = this.workQueues[priority]; + if (!q) { + throw Error("assertion failed"); + } + return new Promise<T>((resolve, reject) => { + this.workQueues[priority].push({operation, args, resolve, reject}); + }); + } + + for (let i = 0; i < this.workers.length; i++) { + let ws = this.workers[i]; + if (ws.busy) { + continue; + } + + ws.busy = true; + this.numBusy++; + + return new Promise<T>((resolve, reject) => { + let msg: any = { + operation, args, + id: this.registerRpcId(resolve, reject, i), + }; + ws.w.postMessage(msg); + }); + } + + throw Error("assertion failed"); + } + + + createPreCoin(denom: Denomination, reserve: Reserve): Promise<PreCoin> { + return this.doRpc("createPreCoin", 1, denom, reserve); + } + + hashString(str: string): Promise<string> { + return this.doRpc("hashString", 1, str); + } + + hashRsaPub(rsaPub: string): Promise<string> { + return this.doRpc("hashRsaPub", 2, rsaPub); + } + + isValidDenom(denom: Denomination, + masterPub: string): Promise<boolean> { + return this.doRpc("isValidDenom", 2, denom, masterPub); + } + + signDeposit(offer: Offer, + cds: CoinWithDenom[]): Promise<PayCoinInfo> { + return this.doRpc("signDeposit", 3, offer, cds); + } + + createEddsaKeypair(): Promise<{priv: string, pub: string}> { + return this.doRpc("createEddsaKeypair", 1); + } + + rsaUnblind(sig: string, bk: string, pk: string): Promise<string> { + return this.doRpc("rsaUnblind", 4, sig, bk, pk); + } +} diff --git a/lib/wallet/cryptoLib.ts b/lib/wallet/cryptoLib.ts new file mode 100644 index 000000000..9a77b3d74 --- /dev/null +++ b/lib/wallet/cryptoLib.ts @@ -0,0 +1,227 @@ +/* + This file is part of TALER + (C) 2016 GNUnet e.V. + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Web worker for crypto operations. + * @author Florian Dold + */ + +"use strict"; + +import * as native from "./emscriptif"; +import {PreCoin, Reserve, PayCoinInfo} from "./types"; +import create = chrome.alarms.create; +import {Offer} from "./wallet"; +import {CoinWithDenom} from "./wallet"; +import {CoinPaySig} from "./types"; +import {Denomination} from "./types"; +import {Amount} from "./emscriptif"; + + +export function main(worker: Worker) { + worker.onmessage = (msg: MessageEvent) => { + if (!Array.isArray(msg.data.args)) { + console.error("args must be array"); + return; + } + if (typeof msg.data.id != "number") { + console.error("RPC id must be number"); + } + if (typeof msg.data.operation != "string") { + console.error("RPC operation must be string"); + } + let f = (RpcFunctions as any)[msg.data.operation]; + if (!f) { + console.error(`unknown operation: '${msg.data.operation}'`); + return; + } + let res = f(...msg.data.args); + worker.postMessage({result: res, id: msg.data.id}); + } +} + + +namespace RpcFunctions { + + /** + * Create a pre-coin of the given denomination to be withdrawn from then given + * reserve. + */ + export function createPreCoin(denom: Denomination, + reserve: Reserve): PreCoin { + let reservePriv = new native.EddsaPrivateKey(); + reservePriv.loadCrock(reserve.reserve_priv); + let reservePub = new native.EddsaPublicKey(); + reservePub.loadCrock(reserve.reserve_pub); + let denomPub = native.RsaPublicKey.fromCrock(denom.denom_pub); + let coinPriv = native.EddsaPrivateKey.create(); + let coinPub = coinPriv.getPublicKey(); + let blindingFactor = native.RsaBlindingKeySecret.create(); + let pubHash: native.HashCode = coinPub.hash(); + let ev: native.ByteArray = native.rsaBlind(pubHash, + blindingFactor, + denomPub); + + if (!denom.fee_withdraw) { + throw Error("Field fee_withdraw missing"); + } + + let amountWithFee = new native.Amount(denom.value); + amountWithFee.add(new native.Amount(denom.fee_withdraw)); + let withdrawFee = new native.Amount(denom.fee_withdraw); + + // Signature + let withdrawRequest = new native.WithdrawRequestPS({ + reserve_pub: reservePub, + amount_with_fee: amountWithFee.toNbo(), + withdraw_fee: withdrawFee.toNbo(), + h_denomination_pub: denomPub.encode().hash(), + h_coin_envelope: ev.hash() + }); + + var sig = native.eddsaSign(withdrawRequest.toPurpose(), reservePriv); + + let preCoin: PreCoin = { + reservePub: reservePub.toCrock(), + blindingKey: blindingFactor.toCrock(), + coinPub: coinPub.toCrock(), + coinPriv: coinPriv.toCrock(), + denomPub: denomPub.encode().toCrock(), + exchangeBaseUrl: reserve.exchange_base_url, + withdrawSig: sig.toCrock(), + coinEv: ev.toCrock(), + coinValue: denom.value + }; + return preCoin; + } + + + export function isValidDenom(denom: Denomination, + masterPub: string): boolean { + let p = new native.DenominationKeyValidityPS({ + master: native.EddsaPublicKey.fromCrock(masterPub), + denom_hash: native.RsaPublicKey.fromCrock(denom.denom_pub) + .encode() + .hash(), + expire_legal: native.AbsoluteTimeNbo.fromTalerString(denom.stamp_expire_legal), + expire_spend: native.AbsoluteTimeNbo.fromTalerString(denom.stamp_expire_deposit), + expire_withdraw: native.AbsoluteTimeNbo.fromTalerString(denom.stamp_expire_withdraw), + start: native.AbsoluteTimeNbo.fromTalerString(denom.stamp_start), + value: (new native.Amount(denom.value)).toNbo(), + fee_deposit: (new native.Amount(denom.fee_deposit)).toNbo(), + fee_refresh: (new native.Amount(denom.fee_refresh)).toNbo(), + fee_withdraw: (new native.Amount(denom.fee_withdraw)).toNbo(), + fee_refund: (new native.Amount(denom.fee_refund)).toNbo(), + }); + + let nativeSig = new native.EddsaSignature(); + nativeSig.loadCrock(denom.master_sig); + + let nativePub = native.EddsaPublicKey.fromCrock(masterPub); + + return native.eddsaVerify(native.SignaturePurpose.MASTER_DENOMINATION_KEY_VALIDITY, + p.toPurpose(), + nativeSig, + nativePub); + + } + + export function hashString(str: string): string { + const b = native.ByteArray.fromString(str); + return b.hash().toCrock(); + } + + + export function hashRsaPub(rsaPub: string): string { + return native.RsaPublicKey.fromCrock(rsaPub) + .encode() + .hash() + .toCrock(); + } + + + export function createEddsaKeypair(): {priv: string, pub: string} { + const priv = native.EddsaPrivateKey.create(); + const pub = priv.getPublicKey(); + return {priv: priv.toCrock(), pub: pub.toCrock()}; + } + + + export function rsaUnblind(sig: string, bk: string, pk: string): string { + let denomSig = native.rsaUnblind(native.RsaSignature.fromCrock(sig), + native.RsaBlindingKeySecret.fromCrock(bk), + native.RsaPublicKey.fromCrock(pk)); + return denomSig.encode().toCrock() + } + + + /** + * Generate updated coins (to store in the database) + * and deposit permissions for each given coin. + */ + export function signDeposit(offer: Offer, + cds: CoinWithDenom[]): PayCoinInfo { + let ret: PayCoinInfo = []; + let amountSpent = native.Amount.getZero(cds[0].coin.currentAmount.currency); + let amountRemaining = new native.Amount(offer.contract.amount); + for (let cd of cds) { + let coinSpend: Amount; + + if (amountRemaining.value == 0 && amountRemaining.fraction == 0) { + break; + } + + if (amountRemaining.cmp(new native.Amount(cd.coin.currentAmount)) < 0) { + coinSpend = new native.Amount(amountRemaining.toJson()); + } else { + coinSpend = new native.Amount(cd.coin.currentAmount); + } + + amountSpent.add(coinSpend); + amountRemaining.sub(coinSpend); + + let newAmount = new native.Amount(cd.coin.currentAmount); + newAmount.sub(coinSpend); + cd.coin.currentAmount = newAmount.toJson(); + + let d = new native.DepositRequestPS({ + h_contract: native.HashCode.fromCrock(offer.H_contract), + h_wire: native.HashCode.fromCrock(offer.contract.H_wire), + amount_with_fee: coinSpend.toNbo(), + coin_pub: native.EddsaPublicKey.fromCrock(cd.coin.coinPub), + deposit_fee: new native.Amount(cd.denom.fee_deposit).toNbo(), + merchant: native.EddsaPublicKey.fromCrock(offer.contract.merchant_pub), + refund_deadline: native.AbsoluteTimeNbo.fromTalerString(offer.contract.refund_deadline), + timestamp: native.AbsoluteTimeNbo.fromTalerString(offer.contract.timestamp), + transaction_id: native.UInt64.fromNumber(offer.contract.transaction_id), + }); + + let coinSig = native.eddsaSign(d.toPurpose(), + native.EddsaPrivateKey.fromCrock(cd.coin.coinPriv)) + .toCrock(); + + let s: CoinPaySig = { + coin_sig: coinSig, + coin_pub: cd.coin.coinPub, + ub_sig: cd.coin.denomSig, + denom_pub: cd.coin.denomPub, + f: coinSpend.toJson(), + }; + ret.push({sig: s, updatedCoin: cd.coin}); + } + return ret; + } +} diff --git a/lib/wallet/cryptoWorker.ts b/lib/wallet/cryptoWorker.ts new file mode 100644 index 000000000..4483c64e6 --- /dev/null +++ b/lib/wallet/cryptoWorker.ts @@ -0,0 +1,61 @@ +/* + This file is part of TALER + (C) 2016 GNUnet e.V. + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Web worker for crypto operations. + * @author Florian Dold + */ + +"use strict"; + + +importScripts("../emscripten/libwrapper.js", + "../vendor/system-csp-production.src.js"); + + +// TypeScript does not allow ".js" extensions in the +// module name, so SystemJS must add it. +System.config({ + defaultJSExtensions: true, + }); + +// We expect that in the manifest, the emscripten js is loaded +// becore the background page. +// Currently it is not possible to use SystemJS to load the emscripten js. +declare var Module: any; +if ("object" !== typeof Module) { + throw Error("emscripten not loaded, no 'Module' defined"); +} + + +// Manually register the emscripten js as a SystemJS, so that +// we can use it from TypeScript by importing it. + +{ + let mod = System.newModule({Module: Module}); + let modName = System.normalizeSync("../emscripten/emsc"); + console.log("registering", modName); + System.set(modName, mod); +} + +System.import("./cryptoLib") + .then((m) => { + m.main(self); + }) + .catch((e) => { + console.log("crypto worker failed"); + console.error(e.stack); + });
\ No newline at end of file diff --git a/lib/wallet/db.ts b/lib/wallet/db.ts new file mode 100644 index 000000000..5104f28fb --- /dev/null +++ b/lib/wallet/db.ts @@ -0,0 +1,119 @@ +/* + This file is part of TALER + (C) 2016 GNUnet e.V. + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +"use strict"; +import Dictionary = _.Dictionary; + +/** + * Declarations and helpers for + * things that are stored in the wallet's + * database. + * @module Db + * @author Florian Dold + */ + +const DB_NAME = "taler"; +const DB_VERSION = 7; + +/** + * Return a promise that resolves + * to the taler wallet db. + */ +export function openTalerDb(): Promise<IDBDatabase> { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onerror = (e) => { + reject(e); + }; + req.onsuccess = (e) => { + resolve(req.result); + }; + req.onupgradeneeded = (e) => { + const db = req.result; + console.log("DB: upgrade needed: oldVersion = " + e.oldVersion); + switch (e.oldVersion) { + case 0: // DB does not exist yet + const exchanges = db.createObjectStore("exchanges", + {keyPath: "baseUrl"}); + exchanges.createIndex("pubKey", "masterPublicKey"); + db.createObjectStore("reserves", {keyPath: "reserve_pub"}); + const coins = db.createObjectStore("coins", {keyPath: "coinPub"}); + coins.createIndex("exchangeBaseUrl", "exchangeBaseUrl"); + const transactions = db.createObjectStore("transactions", + {keyPath: "contractHash"}); + transactions.createIndex("repurchase", + [ + "contract.merchant_pub", + "contract.repurchase_correlation_id" + ]); + + db.createObjectStore("precoins", + {keyPath: "coinPub", autoIncrement: true}); + const history = db.createObjectStore("history", + { + keyPath: "id", + autoIncrement: true + }); + history.createIndex("timestamp", "timestamp"); + break; + default: + if (e.oldVersion != DB_VERSION) { + window.alert("Incompatible wallet dababase version, please reset" + + " db."); + chrome.browserAction.setBadgeText({text: "R!"}); + chrome.browserAction.setBadgeBackgroundColor({color: "#F00"}); + throw Error("incompatible DB"); + } + break; + } + }; + }); +} + + +export function exportDb(db: IDBDatabase): Promise<any> { + let dump = { + name: db.name, + version: db.version, + stores: {} as Dictionary<any>, + }; + + return new Promise((resolve, reject) => { + + let tx = db.transaction(Array.from(db.objectStoreNames)); + tx.addEventListener("complete", () => { + resolve(dump); + }); + for (let i = 0; i < db.objectStoreNames.length; i++) { + let name = db.objectStoreNames[i]; + let storeDump = {} as Dictionary<any>; + dump.stores[name] = storeDump; + let store = tx.objectStore(name) + .openCursor() + .addEventListener("success", (e: Event) => { + let cursor = (e.target as any).result; + if (cursor) { + storeDump[cursor.key] = cursor.value; + cursor.continue(); + } + }); + } + }); +} + +export function deleteDb() { + indexedDB.deleteDatabase(DB_NAME); +}
\ No newline at end of file diff --git a/lib/wallet/emscriptif.ts b/lib/wallet/emscriptif.ts new file mode 100644 index 000000000..5879300e7 --- /dev/null +++ b/lib/wallet/emscriptif.ts @@ -0,0 +1,1033 @@ +/* + This file is part of TALER + (C) 2015 GNUnet e.V. + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import {AmountJson} from "./types"; +import * as EmscWrapper from "../emscripten/emsc"; + +/** + * High-level interface to emscripten-compiled modules used + * by the wallet. + * @module EmscriptIf + * @author Florian Dold + */ + +"use strict"; + +// Size of a native pointer. +const PTR_SIZE = 4; + +const GNUNET_OK = 1; +const GNUNET_YES = 1; +const GNUNET_NO = 0; +const GNUNET_SYSERR = -1; + +let Module = EmscWrapper.Module; + +let getEmsc: EmscWrapper.EmscFunGen = (...args: any[]) => Module.cwrap.apply( + null, + args); + +var emsc = { + free: (ptr: number) => Module._free(ptr), + get_value: getEmsc('TALER_WR_get_value', + 'number', + ['number']), + get_fraction: getEmsc('TALER_WR_get_fraction', + 'number', + ['number']), + get_currency: getEmsc('TALER_WR_get_currency', + 'string', + ['number']), + amount_add: getEmsc('TALER_amount_add', + 'number', + ['number', 'number', 'number']), + amount_subtract: getEmsc('TALER_amount_subtract', + 'number', + ['number', 'number', 'number']), + amount_normalize: getEmsc('TALER_amount_normalize', + 'void', + ['number']), + amount_get_zero: getEmsc('TALER_amount_get_zero', + 'number', + ['string', 'number']), + amount_cmp: getEmsc('TALER_amount_cmp', + 'number', + ['number', 'number']), + amount_hton: getEmsc('TALER_amount_hton', + 'void', + ['number', 'number']), + amount_ntoh: getEmsc('TALER_amount_ntoh', + 'void', + ['number', 'number']), + hash: getEmsc('GNUNET_CRYPTO_hash', + 'void', + ['number', 'number', 'number']), + memmove: getEmsc('memmove', + 'number', + ['number', 'number', 'number']), + rsa_public_key_free: getEmsc('GNUNET_CRYPTO_rsa_public_key_free', + 'void', + ['number']), + rsa_signature_free: getEmsc('GNUNET_CRYPTO_rsa_signature_free', + 'void', + ['number']), + string_to_data: getEmsc('GNUNET_STRINGS_string_to_data', + 'number', + ['number', 'number', 'number', 'number']), + eddsa_sign: getEmsc('GNUNET_CRYPTO_eddsa_sign', + 'number', + ['number', 'number', 'number']), + eddsa_verify: getEmsc('GNUNET_CRYPTO_eddsa_verify', + 'number', + ['number', 'number', 'number', 'number']), + hash_create_random: getEmsc('GNUNET_CRYPTO_hash_create_random', + 'void', + ['number', 'number']), + rsa_blinding_key_destroy: getEmsc('GNUNET_CRYPTO_rsa_blinding_key_free', + 'void', + ['number']), + random_block: getEmsc('GNUNET_CRYPTO_random_block', + 'void', + ['number', 'number', 'number']), +}; + +var emscAlloc = { + get_amount: getEmsc('TALER_WRALL_get_amount', + 'number', + ['number', 'number', 'number', 'string']), + eddsa_key_create: getEmsc('GNUNET_CRYPTO_eddsa_key_create', + 'number', []), + eddsa_public_key_from_private: getEmsc( + 'TALER_WRALL_eddsa_public_key_from_private', + 'number', + ['number']), + data_to_string_alloc: getEmsc('GNUNET_STRINGS_data_to_string_alloc', + 'number', + ['number', 'number']), + purpose_create: getEmsc('TALER_WRALL_purpose_create', + 'number', + ['number', 'number', 'number']), + rsa_blind: getEmsc('GNUNET_CRYPTO_rsa_blind', + 'number', + ['number', 'number', 'number', 'number']), + rsa_blinding_key_create: getEmsc('GNUNET_CRYPTO_rsa_blinding_key_create', + 'number', + ['number']), + rsa_blinding_key_encode: getEmsc('GNUNET_CRYPTO_rsa_blinding_key_encode', + 'number', + ['number', 'number']), + rsa_signature_encode: getEmsc('GNUNET_CRYPTO_rsa_signature_encode', + 'number', + ['number', 'number']), + rsa_blinding_key_decode: getEmsc('GNUNET_CRYPTO_rsa_blinding_key_decode', + 'number', + ['number', 'number']), + rsa_public_key_decode: getEmsc('GNUNET_CRYPTO_rsa_public_key_decode', + 'number', + ['number', 'number']), + rsa_signature_decode: getEmsc('GNUNET_CRYPTO_rsa_signature_decode', + 'number', + ['number', 'number']), + rsa_public_key_encode: getEmsc('GNUNET_CRYPTO_rsa_public_key_encode', + 'number', + ['number', 'number']), + rsa_unblind: getEmsc('GNUNET_CRYPTO_rsa_unblind', + 'number', + ['number', 'number', 'number']), + malloc: (size: number) => Module._malloc(size), +}; + + +export enum SignaturePurpose { + RESERVE_WITHDRAW = 1200, + WALLET_COIN_DEPOSIT = 1201, + MASTER_DENOMINATION_KEY_VALIDITY = 1025, +} + +enum RandomQuality { + WEAK = 0, + STRONG = 1, + NONCE = 2 +} + + +abstract class ArenaObject { + protected _nativePtr: number | undefined = undefined; + arena: Arena; + + abstract destroy(): void; + + constructor(arena?: Arena) { + if (!arena) { + if (arenaStack.length == 0) { + throw Error("No arena available") + } + arena = arenaStack[arenaStack.length - 1]; + } + arena.put(this); + this.arena = arena; + } + + getNative(): number { + // We want to allow latent allocation + // of native wrappers, but we never want to + // pass 'undefined' to emscripten. + if (this._nativePtr === undefined) { + throw Error("Native pointer not initialized"); + } + return this._nativePtr; + } + + free() { + if (this.nativePtr) { + emsc.free(this.nativePtr); + this._nativePtr = undefined; + } + } + + alloc(size: number) { + if (this._nativePtr !== undefined) { + throw Error("Double allocation"); + } + this.nativePtr = emscAlloc.malloc(size); + } + + setNative(n: number) { + if (n === undefined) { + throw Error("Native pointer must be a number or null"); + } + this._nativePtr = n; + } + + set nativePtr(v: number) { + this.setNative(v); + } + + get nativePtr() { + return this.getNative(); + } +} + + +interface Arena { + put(obj: ArenaObject): void; + destroy(): void; +} + + +class DefaultArena implements Arena { + heap: Array<ArenaObject>; + + constructor() { + this.heap = []; + } + + put(obj: ArenaObject) { + this.heap.push(obj); + } + + destroy() { + for (let obj of this.heap) { + obj.destroy(); + } + this.heap = [] + } +} + + +function mySetTimeout(ms: number, fn: () => void) { + // We need to use different timeouts, depending on whether + // we run in node or a web extension + if ("function" === typeof setTimeout) { + setTimeout(fn, ms); + } else { + chrome.extension.getBackgroundPage().setTimeout(fn, ms); + } +} + + +/** + * Arena that destroys all its objects once control has returned to the message + * loop and a small interval has passed. + */ +class SyncArena extends DefaultArena { + private isScheduled: boolean; + + constructor() { + super(); + } + + pub(obj: ArenaObject) { + super.put(obj); + if (!this.isScheduled) { + this.schedule(); + } + this.heap.push(obj); + } + + destroy() { + super.destroy(); + } + + private schedule() { + this.isScheduled = true; + mySetTimeout(50, () => { + this.isScheduled = false; + this.destroy(); + }); + } +} + +let arenaStack: Arena[] = []; +arenaStack.push(new SyncArena()); + + +export class Amount extends ArenaObject { + constructor(args?: AmountJson, arena?: Arena) { + super(arena); + if (args) { + this.nativePtr = emscAlloc.get_amount(args.value, + 0, + args.fraction, + args.currency); + } else { + this.nativePtr = emscAlloc.get_amount(0, 0, 0, ""); + } + } + + destroy() { + super.free(); + } + + + static getZero(currency: string, a?: Arena): Amount { + let am = new Amount(undefined, a); + let r = emsc.amount_get_zero(currency, am.getNative()); + if (r != GNUNET_OK) { + throw Error("invalid currency"); + } + return am; + } + + + toNbo(a?: Arena): AmountNbo { + let x = new AmountNbo(a); + x.alloc(); + emsc.amount_hton(x.nativePtr, this.nativePtr); + return x; + } + + fromNbo(nbo: AmountNbo): void { + emsc.amount_ntoh(this.nativePtr, nbo.nativePtr); + } + + get value() { + return emsc.get_value(this.nativePtr); + } + + get fraction() { + return emsc.get_fraction(this.nativePtr); + } + + get currency(): String { + return emsc.get_currency(this.nativePtr); + } + + toJson(): AmountJson { + return { + value: emsc.get_value(this.nativePtr), + fraction: emsc.get_fraction(this.nativePtr), + currency: emsc.get_currency(this.nativePtr) + }; + } + + /** + * Add an amount to this amount. + */ + add(a: Amount) { + let res = emsc.amount_add(this.nativePtr, a.nativePtr, this.nativePtr); + if (res < 1) { + // Overflow + return false; + } + return true; + } + + /** + * Perform saturating subtraction on amounts. + */ + sub(a: Amount) { + // this = this - a + let res = emsc.amount_subtract(this.nativePtr, this.nativePtr, a.nativePtr); + if (res == 0) { + // Underflow + return false; + } + if (res > 0) { + return true; + } + throw Error("Incompatible currencies"); + } + + cmp(a: Amount) { + // If we don't check this, the c code aborts. + if (this.currency !== a.currency) { + throw Error(`incomparable currencies (${this.currency} and ${a.currency})`); + } + return emsc.amount_cmp(this.nativePtr, a.nativePtr); + } + + normalize() { + emsc.amount_normalize(this.nativePtr); + } +} + + +/** + * Count the UTF-8 characters in a JavaScript string. + */ +function countBytes(str: string): number { + var s = str.length; + // JavaScript strings are UTF-16 arrays + for (let i = str.length - 1; i >= 0; i--) { + var code = str.charCodeAt(i); + if (code > 0x7f && code <= 0x7ff) { + // We need an extra byte in utf-8 here + s++; + } else if (code > 0x7ff && code <= 0xffff) { + // We need two extra bytes in utf-8 here + s += 2; + } + // Skip over the other surrogate + if (code >= 0xDC00 && code <= 0xDFFF) { + i--; + } + } + return s; +} + + +/** + * Managed reference to a contiguous block of memory in the Emscripten heap. + * Should contain only data, not pointers. + */ +abstract class PackedArenaObject extends ArenaObject { + abstract size(): number; + + constructor(a?: Arena) { + super(a); + } + + randomize(qual: RandomQuality = RandomQuality.STRONG): void { + emsc.random_block(qual, this.nativePtr, this.size()); + } + + toCrock(): string { + var d = emscAlloc.data_to_string_alloc(this.nativePtr, this.size()); + var s = Module.Pointer_stringify(d); + emsc.free(d); + return s; + } + + toJson(): any { + // Per default, the json encoding of + // packed arena objects is just the crockford encoding. + // Subclasses typically want to override this. + return this.toCrock(); + } + + loadCrock(s: string) { + this.alloc(); + // We need to get the javascript string + // to the emscripten heap first. + let buf = ByteArray.fromString(s); + let res = emsc.string_to_data(buf.nativePtr, + s.length, + this.nativePtr, + this.size()); + buf.destroy(); + if (res < 1) { + throw {error: "wrong encoding"}; + } + } + + alloc() { + // FIXME: should the client be allowed to call alloc multiple times? + if (!this._nativePtr) { + this.nativePtr = emscAlloc.malloc(this.size()); + } + } + + destroy() { + emsc.free(this.nativePtr); + this.nativePtr = 0; + } + + hash(): HashCode { + var x = new HashCode(); + x.alloc(); + emsc.hash(this.nativePtr, this.size(), x.nativePtr); + return x; + } + + hexdump() { + let bytes: string[] = []; + for (let i = 0; i < this.size(); i++) { + let b = Module.getValue(this.getNative() + i, "i8"); + b = (b + 256) % 256; + bytes.push("0".concat(b.toString(16)).slice(-2)); + } + let lines: string[] = []; + for (let i = 0; i < bytes.length; i += 8) { + lines.push(bytes.slice(i, i + 8).join(",")); + } + return lines.join("\n"); + } +} + + +export class AmountNbo extends PackedArenaObject { + size() { + return 24; + } + + toJson(): any { + let a = new DefaultArena(); + let am = new Amount(undefined, a); + am.fromNbo(this); + let json = am.toJson(); + a.destroy(); + return json; + } +} + + +export class EddsaPrivateKey extends PackedArenaObject { + static create(a?: Arena): EddsaPrivateKey { + let obj = new EddsaPrivateKey(a); + obj.nativePtr = emscAlloc.eddsa_key_create(); + return obj; + } + + size() { + return 32; + } + + getPublicKey(a?: Arena): EddsaPublicKey { + let obj = new EddsaPublicKey(a); + obj.nativePtr = emscAlloc.eddsa_public_key_from_private(this.nativePtr); + return obj; + } + + static fromCrock: (s: string) => EddsaPrivateKey; +} +mixinStatic(EddsaPrivateKey, fromCrock); + + +function fromCrock(s: string) { + let x = new this(); + x.alloc(); + x.loadCrock(s); + return x; +} + + +function mixin(obj: any, method: any, name?: string) { + if (!name) { + name = method.name; + } + if (!name) { + throw Error("Mixin needs a name."); + } + obj.prototype[method.name] = method; +} + + +function mixinStatic(obj: any, method: any, name?: string) { + if (!name) { + name = method.name; + } + if (!name) { + throw Error("Mixin needs a name."); + } + obj[method.name] = method; +} + + +export class EddsaPublicKey extends PackedArenaObject { + size() { + return 32; + } + + static fromCrock: (s: string) => EddsaPublicKey; +} +mixinStatic(EddsaPublicKey, fromCrock); + +function makeFromCrock(decodeFn: (p: number, s: number) => number) { + function fromCrock(s: string, a?: Arena) { + let obj = new this(a); + let buf = ByteArray.fromCrock(s); + obj.setNative(decodeFn(buf.getNative(), + buf.size())); + buf.destroy(); + return obj; + } + + return fromCrock; +} + +function makeToCrock(encodeFn: (po: number, + ps: number) => number): () => string { + function toCrock() { + let ptr = emscAlloc.malloc(PTR_SIZE); + let size = emscAlloc.rsa_blinding_key_encode(this.nativePtr, ptr); + let res = new ByteArray(size, Module.getValue(ptr, '*')); + let s = res.toCrock(); + emsc.free(ptr); + res.destroy(); + return s; + } + + return toCrock; +} + +export class RsaBlindingKeySecret extends PackedArenaObject { + size() { + return 32; + } + + /** + * Create a random blinding key secret. + */ + static create(a?: Arena): RsaBlindingKeySecret { + let o = new RsaBlindingKeySecret(a); + o.alloc(); + o.randomize(); + return o; + } + + static fromCrock: (s: string) => RsaBlindingKeySecret; +} +mixinStatic(RsaBlindingKeySecret, fromCrock); + + +export class HashCode extends PackedArenaObject { + size() { + return 64; + } + + static fromCrock: (s: string) => HashCode; + + random(qual: RandomQuality = RandomQuality.STRONG) { + this.alloc(); + emsc.hash_create_random(qual, this.nativePtr); + } +} +mixinStatic(HashCode, fromCrock); + + +export class ByteArray extends PackedArenaObject { + private allocatedSize: number; + + size() { + return this.allocatedSize; + } + + constructor(desiredSize: number, init?: number, a?: Arena) { + super(a); + if (init === undefined) { + this.nativePtr = emscAlloc.malloc(desiredSize); + } else { + this.nativePtr = init; + } + this.allocatedSize = desiredSize; + } + + static fromString(s: string, a?: Arena): ByteArray { + // UTF-8 bytes, including 0-terminator + let terminatedByteLength = countBytes(s) + 1; + let hstr = emscAlloc.malloc(terminatedByteLength); + Module.stringToUTF8(s, hstr, terminatedByteLength); + return new ByteArray(terminatedByteLength, hstr, a); + } + + static fromCrock(s: string, a?: Arena): ByteArray { + let byteLength = countBytes(s); + let hstr = emscAlloc.malloc(byteLength + 1); + Module.stringToUTF8(s, hstr, byteLength + 1); + let decodedLen = Math.floor((byteLength * 5) / 8); + let ba = new ByteArray(decodedLen, undefined, a); + let res = emsc.string_to_data(hstr, byteLength, ba.nativePtr, decodedLen); + emsc.free(hstr); + if (res != GNUNET_OK) { + throw Error("decoding failed"); + } + return ba; + } +} + + +export class EccSignaturePurpose extends PackedArenaObject { + size() { + return this.payloadSize + 8; + } + + payloadSize: number; + + constructor(purpose: SignaturePurpose, + payload: PackedArenaObject, + a?: Arena) { + super(a); + this.nativePtr = emscAlloc.purpose_create(purpose, + payload.nativePtr, + payload.size()); + this.payloadSize = payload.size(); + } +} + + +abstract class SignatureStruct { + abstract fieldTypes(): Array<any>; + + abstract purpose(): SignaturePurpose; + + private members: any = {}; + + constructor(x: { [name: string]: any }) { + for (let k in x) { + this.set(k, x[k]); + } + } + + toPurpose(a?: Arena): EccSignaturePurpose { + let totalSize = 0; + for (let f of this.fieldTypes()) { + let name = f[0]; + let member = this.members[name]; + if (!member) { + throw Error(`Member ${name} not set`); + } + totalSize += member.size(); + } + + let buf = emscAlloc.malloc(totalSize); + let ptr = buf; + for (let f of this.fieldTypes()) { + let name = f[0]; + let member = this.members[name]; + let size = member.size(); + emsc.memmove(ptr, member.nativePtr, size); + ptr += size; + } + let ba = new ByteArray(totalSize, buf, a); + return new EccSignaturePurpose(this.purpose(), ba); + } + + + toJson() { + let res: any = {}; + for (let f of this.fieldTypes()) { + let name = f[0]; + let member = this.members[name]; + if (!member) { + throw Error(`Member ${name} not set`); + } + res[name] = member.toJson(); + } + res["purpose"] = this.purpose(); + return res; + } + + protected set(name: string, value: PackedArenaObject) { + let typemap: any = {}; + for (let f of this.fieldTypes()) { + typemap[f[0]] = f[1]; + } + if (!(name in typemap)) { + throw Error(`Key ${name} not found`); + } + if (!(value instanceof typemap[name])) { + throw Error("Wrong type for ${name}"); + } + this.members[name] = value; + } +} + + +// It's redundant, but more type safe. +export interface WithdrawRequestPS_Args { + reserve_pub: EddsaPublicKey; + amount_with_fee: AmountNbo; + withdraw_fee: AmountNbo; + h_denomination_pub: HashCode; + h_coin_envelope: HashCode; +} + + +export class WithdrawRequestPS extends SignatureStruct { + constructor(w: WithdrawRequestPS_Args) { + super(w); + } + + purpose() { + return SignaturePurpose.RESERVE_WITHDRAW; + } + + fieldTypes() { + return [ + ["reserve_pub", EddsaPublicKey], + ["amount_with_fee", AmountNbo], + ["withdraw_fee", AmountNbo], + ["h_denomination_pub", HashCode], + ["h_coin_envelope", HashCode] + ]; + } +} + + +export class AbsoluteTimeNbo extends PackedArenaObject { + static fromTalerString(s: string): AbsoluteTimeNbo { + let x = new AbsoluteTimeNbo(); + x.alloc(); + let r = /Date\(([0-9]+)\)/; + let m = r.exec(s); + if (!m || m.length != 2) { + throw Error(); + } + let n = parseInt(m[1]) * 1000000; + // XXX: This only works up to 54 bit numbers. + set64(x.getNative(), n); + return x; + } + + size() { + return 8; + } +} + + +// XXX: This only works up to 54 bit numbers. +function set64(p: number, n: number) { + for (let i = 0; i < 8; ++i) { + Module.setValue(p + (7 - i), n & 0xFF, "i8"); + n = Math.floor(n / 256); + } + +} + + +export class UInt64 extends PackedArenaObject { + static fromNumber(n: number): UInt64 { + let x = new UInt64(); + x.alloc(); + set64(x.getNative(), n); + return x; + } + + size() { + return 8; + } +} + + +// It's redundant, but more type safe. +export interface DepositRequestPS_Args { + h_contract: HashCode; + h_wire: HashCode; + timestamp: AbsoluteTimeNbo; + refund_deadline: AbsoluteTimeNbo; + transaction_id: UInt64; + amount_with_fee: AmountNbo; + deposit_fee: AmountNbo; + merchant: EddsaPublicKey; + coin_pub: EddsaPublicKey; +} + + +export class DepositRequestPS extends SignatureStruct { + constructor(w: DepositRequestPS_Args) { + super(w); + } + + purpose() { + return SignaturePurpose.WALLET_COIN_DEPOSIT; + } + + fieldTypes() { + return [ + ["h_contract", HashCode], + ["h_wire", HashCode], + ["timestamp", AbsoluteTimeNbo], + ["refund_deadline", AbsoluteTimeNbo], + ["transaction_id", UInt64], + ["amount_with_fee", AmountNbo], + ["deposit_fee", AmountNbo], + ["merchant", EddsaPublicKey], + ["coin_pub", EddsaPublicKey], + ]; + } +} + +export interface DenominationKeyValidityPS_args { + master: EddsaPublicKey; + start: AbsoluteTimeNbo; + expire_withdraw: AbsoluteTimeNbo; + expire_spend: AbsoluteTimeNbo; + expire_legal: AbsoluteTimeNbo; + value: AmountNbo; + fee_withdraw: AmountNbo; + fee_deposit: AmountNbo; + fee_refresh: AmountNbo; + fee_refund: AmountNbo; + denom_hash: HashCode; +} + +export class DenominationKeyValidityPS extends SignatureStruct { + constructor(w: DenominationKeyValidityPS_args) { + super(w); + } + + purpose() { + return SignaturePurpose.MASTER_DENOMINATION_KEY_VALIDITY; + } + + fieldTypes() { + return [ + ["master", EddsaPublicKey], + ["start", AbsoluteTimeNbo], + ["expire_withdraw", AbsoluteTimeNbo], + ["expire_spend", AbsoluteTimeNbo], + ["expire_legal", AbsoluteTimeNbo], + ["value", AmountNbo], + ["fee_withdraw", AmountNbo], + ["fee_deposit", AmountNbo], + ["fee_refresh", AmountNbo], + ["fee_refund", AmountNbo], + ["denom_hash", HashCode] + ]; + } +} + + +interface Encodeable { + encode(arena?: Arena): ByteArray; +} + +function makeEncode(encodeFn: any) { + function encode(arena?: Arena) { + let ptr = emscAlloc.malloc(PTR_SIZE); + let len = encodeFn(this.getNative(), ptr); + let res = new ByteArray(len, undefined, arena); + res.setNative(Module.getValue(ptr, '*')); + emsc.free(ptr); + return res; + } + + return encode; +} + + +export class RsaPublicKey extends ArenaObject implements Encodeable { + static fromCrock: (s: string, a?: Arena) => RsaPublicKey; + + toCrock() { + return this.encode().toCrock(); + } + + destroy() { + emsc.rsa_public_key_free(this.nativePtr); + this.nativePtr = 0; + } + + encode: (arena?: Arena) => ByteArray; +} +mixinStatic(RsaPublicKey, makeFromCrock(emscAlloc.rsa_public_key_decode)); +mixin(RsaPublicKey, makeEncode(emscAlloc.rsa_public_key_encode)); + + +export class EddsaSignature extends PackedArenaObject { + size() { + return 64; + } +} + + +export class RsaSignature extends ArenaObject implements Encodeable { + static fromCrock: (s: string, a?: Arena) => RsaSignature; + + encode: (arena?: Arena) => ByteArray; + + destroy() { + emsc.rsa_signature_free(this.getNative()); + this.setNative(0); + } +} +mixinStatic(RsaSignature, makeFromCrock(emscAlloc.rsa_signature_decode)); +mixin(RsaSignature, makeEncode(emscAlloc.rsa_signature_encode)); + + +export function rsaBlind(hashCode: HashCode, + blindingKey: RsaBlindingKeySecret, + pkey: RsaPublicKey, + arena?: Arena): ByteArray { + let ptr = emscAlloc.malloc(PTR_SIZE); + let s = emscAlloc.rsa_blind(hashCode.nativePtr, + blindingKey.nativePtr, + pkey.nativePtr, + ptr); + return new ByteArray(s, Module.getValue(ptr, '*'), arena); +} + + +export function eddsaSign(purpose: EccSignaturePurpose, + priv: EddsaPrivateKey, + a?: Arena): EddsaSignature { + let sig = new EddsaSignature(a); + sig.alloc(); + let res = emsc.eddsa_sign(priv.nativePtr, purpose.nativePtr, sig.nativePtr); + if (res < 1) { + throw Error("EdDSA signing failed"); + } + return sig; +} + + +export function eddsaVerify(purposeNum: number, + verify: EccSignaturePurpose, + sig: EddsaSignature, + pub: EddsaPublicKey, + a?: Arena): boolean { + let r = emsc.eddsa_verify(purposeNum, + verify.nativePtr, + sig.nativePtr, + pub.nativePtr); + if (r === GNUNET_OK) { + return true; + } + return false; +} + + +export function rsaUnblind(sig: RsaSignature, + bk: RsaBlindingKeySecret, + pk: RsaPublicKey, + a?: Arena): RsaSignature { + let x = new RsaSignature(a); + x.nativePtr = emscAlloc.rsa_unblind(sig.nativePtr, + bk.nativePtr, + pk.nativePtr); + return x; +} diff --git a/lib/wallet/helpers.ts b/lib/wallet/helpers.ts new file mode 100644 index 000000000..5d231fe64 --- /dev/null +++ b/lib/wallet/helpers.ts @@ -0,0 +1,67 @@ +/* + This file is part of TALER + (C) 2016 GNUnet e.V. + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + + +/** + * Smaller helper functions that do not depend + * on the emscripten machinery. + * + * @author Florian Dold + */ + +import {AmountJson} from "./types"; + +export function substituteFulfillmentUrl(url: string, vars: any) { + url = url.replace("${H_contract}", vars.H_contract); + url = url.replace("${$}", "$"); + return url; +} + + +export function amountToPretty(amount: AmountJson): string { + let x = amount.value + amount.fraction / 1e6; + return `${x} ${amount.currency}`; +} + + +/** + * Canonicalize a base url, typically for the exchange. + * + * See http://api.taler.net/wallet.html#general + */ +export function canonicalizeBaseUrl(url: string) { + let x = new URI(url); + if (!x.protocol()) { + x.protocol("https"); + } + x.path(x.path() + "/").normalizePath(); + x.fragment(); + x.query(); + return x.href() +} + + +export function parsePrettyAmount(pretty: string): AmountJson|undefined { + const res = /([0-9]+)(.[0-9]+)?\s*(\w+)/.exec(pretty); + if (!res) { + return undefined; + } + return { + value: parseInt(res[1], 10), + fraction: res[2] ? (parseFloat(`0.${res[2]}`) * 1e-6) : 0, + currency: res[3] + } +} diff --git a/lib/wallet/http.ts b/lib/wallet/http.ts new file mode 100644 index 000000000..8f82ceaff --- /dev/null +++ b/lib/wallet/http.ts @@ -0,0 +1,84 @@ +/* + This file is part of TALER + (C) 2016 GNUnet e.V. + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Helpers for doing XMLHttpRequest-s that are based on ES6 promises. + * @module Http + * @author Florian Dold + */ + +"use strict"; + + +export interface HttpResponse { + status: number; + responseText: string; +} + + +export class BrowserHttpLib { + req(method: string, + url: string|uri.URI, + options?: any): Promise<HttpResponse> { + let urlString: string; + if (url instanceof URI) { + urlString = url.href(); + } else if (typeof url === "string") { + urlString = url; + } + + return new Promise((resolve, reject) => { + let myRequest = new XMLHttpRequest(); + myRequest.open(method, urlString); + if (options && options.req) { + myRequest.send(options.req); + } else { + myRequest.send(); + } + myRequest.addEventListener("readystatechange", (e) => { + if (myRequest.readyState == XMLHttpRequest.DONE) { + let resp = { + status: myRequest.status, + responseText: myRequest.responseText + }; + resolve(resp); + } + }); + }); + } + + + get(url: string|uri.URI) { + return this.req("get", url); + } + + + postJson(url: string|uri.URI, body: any) { + return this.req("post", url, {req: JSON.stringify(body)}); + } + + + postForm(url: string|uri.URI, form: any) { + return this.req("post", url, {req: form}); + } +} + + +export class RequestException { + constructor(detail: any) { + + } +} diff --git a/lib/wallet/query.ts b/lib/wallet/query.ts new file mode 100644 index 000000000..c7420a3f7 --- /dev/null +++ b/lib/wallet/query.ts @@ -0,0 +1,415 @@ +/* + This file is part of TALER + (C) 2016 GNUnet e.V. + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + + +/** + * Database query abstractions. + * @module Query + * @author Florian Dold + */ + +"use strict"; + + +export function Query(db: IDBDatabase) { + return new QueryRoot(db); +} + +/** + * Stream that can be filtered, reduced or joined + * with indices. + */ +export interface QueryStream<T> { + indexJoin<S>(storeName: string, + indexName: string, + keyFn: (obj: any) => any): QueryStream<[T,S]>; + filter(f: (x: any) => boolean): QueryStream<T>; + reduce<S>(f: (v: T, acc: S) => S, start?: S): Promise<S>; + flatMap(f: (x: T) => T[]): QueryStream<T>; +} + + +/** + * Get an unresolved promise together with its extracted resolve / reject + * function. + */ +function openPromise<T>() { + let resolve: ((value?: T | PromiseLike<T>) => void) | null = null; + let reject: ((reason?: any) => void) | null = null; + const promise = new Promise<T>((res, rej) => { + resolve = res; + reject = rej; + }); + if (!(resolve && reject)) { + // Never happens, unless JS implementation is broken + throw Error(); + } + return {resolve, reject, promise}; +} + + +abstract class QueryStreamBase<T> implements QueryStream<T> { + abstract subscribe(f: (isDone: boolean, + value: any, + tx: IDBTransaction) => void): void; + + root: QueryRoot; + + constructor(root: QueryRoot) { + this.root = root; + } + + flatMap(f: (x: T) => T[]): QueryStream<T> { + return new QueryStreamFlatMap(this, f); + } + + indexJoin<S>(storeName: string, + indexName: string, + key: any): QueryStream<[T,S]> { + this.root.addStoreAccess(storeName, false); + return new QueryStreamIndexJoin(this, storeName, indexName, key); + } + + filter(f: (x: any) => boolean): QueryStream<T> { + return new QueryStreamFilter(this, f); + } + + reduce<A>(f: (x: any, acc?: A) => A, init?: A): Promise<any> { + let {resolve, promise} = openPromise(); + let acc = init; + + this.subscribe((isDone, value) => { + if (isDone) { + resolve(acc); + return; + } + acc = f(value, acc); + }); + + return Promise.resolve() + .then(() => this.root.finish()) + .then(() => promise); + } +} + +type FilterFn = (e: any) => boolean; +type SubscribeFn = (done: boolean, value: any, tx: IDBTransaction) => void; + +interface FlatMapFn<T> { + (v: T): T[]; +} + +class QueryStreamFilter<T> extends QueryStreamBase<T> { + s: QueryStreamBase<T>; + filterFn: FilterFn; + + constructor(s: QueryStreamBase<T>, filterFn: FilterFn) { + super(s.root); + this.s = s; + this.filterFn = filterFn; + } + + subscribe(f: SubscribeFn) { + this.s.subscribe((isDone, value, tx) => { + if (isDone) { + f(true, undefined, tx); + return; + } + if (this.filterFn(value)) { + f(false, value, tx); + } + }); + } +} + + +class QueryStreamFlatMap<T> extends QueryStreamBase<T> { + s: QueryStreamBase<T>; + flatMapFn: (v: T) => T[]; + + constructor(s: QueryStreamBase<T>, flatMapFn: (v: T) => T[]) { + super(s.root); + this.s = s; + this.flatMapFn = flatMapFn; + } + + subscribe(f: SubscribeFn) { + this.s.subscribe((isDone, value, tx) => { + if (isDone) { + f(true, undefined, tx); + return; + } + let values = this.flatMapFn(value); + for (let v in values) { + f(false, value, tx) + } + }); + } +} + + +class QueryStreamIndexJoin<T,S> extends QueryStreamBase<[T, S]> { + s: QueryStreamBase<T>; + storeName: string; + key: any; + indexName: string; + + constructor(s: QueryStreamBase<T>, storeName: string, indexName: string, key: any) { + super(s.root); + this.s = s; + this.storeName = storeName; + this.key = key; + this.indexName = indexName; + } + + subscribe(f: SubscribeFn) { + this.s.subscribe((isDone, value, tx) => { + if (isDone) { + f(true, undefined, tx); + return; + } + console.log("joining on", this.key(value)); + let s = tx.objectStore(this.storeName).index(this.indexName); + let req = s.openCursor(IDBKeyRange.only(this.key(value))); + req.onsuccess = () => { + let cursor = req.result; + if (cursor) { + f(false, [value, cursor.value], tx); + cursor.continue(); + } else { + f(true, undefined, tx); + } + } + }); + } +} + + +class IterQueryStream<T> extends QueryStreamBase<T> { + private storeName: string; + private options: any; + private subscribers: SubscribeFn[]; + + constructor(qr: QueryRoot, storeName: string, options: any) { + super(qr); + this.options = options; + this.storeName = storeName; + this.subscribers = []; + + let doIt = (tx: IDBTransaction) => { + const {indexName = void 0, only = void 0} = this.options; + let s: any; + if (indexName !== void 0) { + s = tx.objectStore(this.storeName) + .index(this.options.indexName); + } else { + s = tx.objectStore(this.storeName); + } + let kr: IDBKeyRange|undefined = undefined; + if (only !== undefined) { + kr = IDBKeyRange.only(this.options.only); + } + let req = s.openCursor(kr); + req.onsuccess = () => { + let cursor: IDBCursorWithValue = req.result; + if (cursor) { + for (let f of this.subscribers) { + f(false, cursor.value, tx); + } + cursor.continue(); + } else { + for (let f of this.subscribers) { + f(true, undefined, tx); + } + } + } + }; + + this.root.addWork(doIt); + } + + subscribe(f: SubscribeFn) { + this.subscribers.push(f); + } +} + + +class QueryRoot { + private work: ((t: IDBTransaction) => void)[] = []; + private db: IDBDatabase; + private stores = new Set(); + private kickoffPromise: Promise<void>; + + /** + * Some operations is a write operation, + * and we need to do a "readwrite" transaction/ + */ + private hasWrite: boolean; + + constructor(db: IDBDatabase) { + this.db = db; + } + + iter<T>(storeName: string, + {only = <string|undefined>undefined, indexName = <string|undefined>undefined} = {}): QueryStream<T> { + this.stores.add(storeName); + return new IterQueryStream(this, storeName, {only, indexName}); + } + + /** + * Put an object into the given object store. + * Overrides if an existing object with the same key exists + * in the store. + */ + put(storeName: string, val: any): QueryRoot { + let doPut = (tx: IDBTransaction) => { + tx.objectStore(storeName).put(val); + }; + this.addWork(doPut, storeName, true); + return this; + } + + + /** + * Add all object from an iterable to the given object store. + * Fails if the object's key is already present + * in the object store. + */ + putAll(storeName: string, iterable: any[]): QueryRoot { + const doPutAll = (tx: IDBTransaction) => { + for (const obj of iterable) { + tx.objectStore(storeName).put(obj); + } + }; + this.addWork(doPutAll, storeName, true); + return this; + } + + /** + * Add an object to the given object store. + * Fails if the object's key is already present + * in the object store. + */ + add(storeName: string, val: any): QueryRoot { + const doAdd = (tx: IDBTransaction) => { + tx.objectStore(storeName).add(val); + }; + this.addWork(doAdd, storeName, true); + return this; + } + + /** + * Get one object from a store by its key. + */ + get(storeName: any, key: any): Promise<any> { + if (key === void 0) { + throw Error("key must not be undefined"); + } + + const {resolve, promise} = openPromise(); + + const doGet = (tx: IDBTransaction) => { + const req = tx.objectStore(storeName).get(key); + req.onsuccess = () => { + resolve(req.result); + }; + }; + + this.addWork(doGet, storeName, false); + return Promise.resolve() + .then(() => this.finish()) + .then(() => promise); + } + + /** + * Get one object from a store by its key. + */ + getIndexed(storeName: string, indexName: string, key: any): Promise<any> { + if (key === void 0) { + throw Error("key must not be undefined"); + } + + const {resolve, promise} = openPromise(); + + const doGetIndexed = (tx: IDBTransaction) => { + const req = tx.objectStore(storeName).index(indexName).get(key); + req.onsuccess = () => { + resolve(req.result); + }; + }; + + this.addWork(doGetIndexed, storeName, false); + return Promise.resolve() + .then(() => this.finish()) + .then(() => promise); + } + + /** + * Finish the query, and start the query in the first place if necessary. + */ + finish(): Promise<void> { + if (this.kickoffPromise) { + return this.kickoffPromise; + } + this.kickoffPromise = new Promise<void>((resolve, reject) => { + if (this.work.length == 0) { + resolve(); + return; + } + const mode = this.hasWrite ? "readwrite" : "readonly"; + const tx = this.db.transaction(Array.from(this.stores), mode); + tx.oncomplete = () => { + resolve(); + }; + for (let w of this.work) { + w(tx); + } + }); + return this.kickoffPromise; + } + + /** + * Delete an object by from the given object store. + */ + delete(storeName: string, key: any): QueryRoot { + const doDelete = (tx: IDBTransaction) => { + tx.objectStore(storeName).delete(key); + }; + this.addWork(doDelete, storeName, true); + return this; + } + + /** + * Low-level function to add a task to the internal work queue. + */ + addWork(workFn: (t: IDBTransaction) => void, + storeName?: string, + isWrite?: boolean) { + this.work.push(workFn); + if (storeName) { + this.addStoreAccess(storeName, isWrite); + } + } + + addStoreAccess(storeName: string, isWrite?: boolean) { + if (storeName) { + this.stores.add(storeName); + } + if (isWrite) { + this.hasWrite = true; + } + } +}
\ No newline at end of file diff --git a/lib/wallet/renderHtml.ts b/lib/wallet/renderHtml.ts new file mode 100644 index 000000000..6d9823d71 --- /dev/null +++ b/lib/wallet/renderHtml.ts @@ -0,0 +1,49 @@ +/* + This file is part of TALER + (C) 2016 INRIA + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Helpers functions to render Taler-related data structures to HTML. + * + * @author Florian Dold + */ + + +import {AmountJson, Contract} from "./types"; + + +export function prettyAmount(amount: AmountJson) { + let v = amount.value + amount.fraction / 1e6; + return `${v.toFixed(2)} ${amount.currency}`; +} + +export function renderContract(contract: Contract): any { + let merchantName = m("strong", contract.merchant.name); + let amount = m("strong", prettyAmount(contract.amount)); + + return m("div", {}, [ + m("p", + i18n.parts`${merchantName} + wants to enter a contract over ${amount} + with you.`), + m("p", + i18n`You are about to purchase:`), + m('ul', + + contract.products.map( + (p: any) => m("li", + `${p.description}: ${prettyAmount(p.price)}`))) + ]); +}
\ No newline at end of file diff --git a/lib/wallet/types.ts b/lib/wallet/types.ts new file mode 100644 index 000000000..e8b7a1e39 --- /dev/null +++ b/lib/wallet/types.ts @@ -0,0 +1,396 @@ +/* + This file is part of TALER + (C) 2015 GNUnet e.V. + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Common types that are used by Taler. + * + * Note most types are defined in wallet.ts, types that + * are defined in types.ts are intended to be used by components + * that do not depend on the whole wallet implementation (which depends on + * emscripten). + * + * @author Florian Dold + */ + +import {Checkable} from "./checkable"; + +@Checkable.Class +export class AmountJson { + @Checkable.Number + value: number; + + @Checkable.Number + fraction: number; + + @Checkable.String + currency: string; + + static checked: (obj: any) => AmountJson; +} + + +@Checkable.Class +export class CreateReserveResponse { + /** + * Exchange URL where the bank should create the reserve. + * The URL is canonicalized in the response. + */ + @Checkable.String + exchange: string; + + @Checkable.String + reservePub: string; + + static checked: (obj: any) => CreateReserveResponse; +} + + +@Checkable.Class +export class Denomination { + @Checkable.Value(AmountJson) + value: AmountJson; + + @Checkable.String + denom_pub: string; + + @Checkable.Value(AmountJson) + fee_withdraw: AmountJson; + + @Checkable.Value(AmountJson) + fee_deposit: AmountJson; + + @Checkable.Value(AmountJson) + fee_refresh: AmountJson; + + @Checkable.Value(AmountJson) + fee_refund: AmountJson; + + @Checkable.String + stamp_start: string; + + @Checkable.String + stamp_expire_withdraw: string; + + @Checkable.String + stamp_expire_legal: string; + + @Checkable.String + stamp_expire_deposit: string; + + @Checkable.String + master_sig: string; + + static checked: (obj: any) => Denomination; +} + + +export interface IExchangeInfo { + baseUrl: string; + masterPublicKey: string; + + /** + * All denominations we ever received from the exchange. + * Expired denominations may be garbage collected. + */ + all_denoms: Denomination[]; + + /** + * Denominations we received with the last update. + * Subset of "denoms". + */ + active_denoms: Denomination[]; + + /** + * Timestamp for last update. + */ + last_update_time: number; +} + +export interface WireInfo { + [type: string]: any; +} + +export interface ReserveCreationInfo { + exchangeInfo: IExchangeInfo; + wireInfo: WireInfo; + selectedDenoms: Denomination[]; + withdrawFee: AmountJson; + overhead: AmountJson; +} + + +/** + * A coin that isn't yet signed by an exchange. + */ +export interface PreCoin { + coinPub: string; + coinPriv: string; + reservePub: string; + denomPub: string; + blindingKey: string; + withdrawSig: string; + coinEv: string; + exchangeBaseUrl: string; + coinValue: AmountJson; +} + + +export interface Reserve { + exchange_base_url: string + reserve_priv: string; + reserve_pub: string; +} + + +export interface CoinPaySig { + coin_sig: string; + coin_pub: string; + ub_sig: string; + denom_pub: string; + f: AmountJson; +} + + +/** + * Coin as stored in the "coins" data store + * of the wallet database. + */ +export interface Coin { + /** + * Public key of the coin. + */ + coinPub: string; + + /** + * Private key to authorize operations on the coin. + */ + coinPriv: string; + + /** + * Key used by the exchange used to sign the coin. + */ + denomPub: string; + + /** + * Unblinded signature by the exchange. + */ + denomSig: string; + + /** + * Amount that's left on the coin. + */ + currentAmount: AmountJson; + + /** + * Base URL that identifies the exchange from which we got the + * coin. + */ + exchangeBaseUrl: string; + + /** + * We have withdrawn the coin, but it's not accepted by the exchange anymore. + * We have to tell an auditor and wait for compensation or for the exchange + * to fix it. + */ + suspended?: boolean; +} + + +@Checkable.Class +export class ExchangeHandle { + @Checkable.String + master_pub: string; + + @Checkable.String + url: string; + + static checked: (obj: any) => ExchangeHandle; +} + + +@Checkable.Class +export class Contract { + @Checkable.String + H_wire: string; + + @Checkable.Value(AmountJson) + amount: AmountJson; + + @Checkable.List(Checkable.AnyObject) + auditors: any[]; + + @Checkable.String + expiry: string; + + @Checkable.Any + locations: any; + + @Checkable.Value(AmountJson) + max_fee: AmountJson; + + @Checkable.Any + merchant: any; + + @Checkable.String + merchant_pub: string; + + @Checkable.List(Checkable.Value(ExchangeHandle)) + exchanges: ExchangeHandle[]; + + @Checkable.List(Checkable.AnyObject) + products: any[]; + + @Checkable.String + refund_deadline: string; + + @Checkable.String + timestamp: string; + + @Checkable.Number + transaction_id: number; + + @Checkable.String + fulfillment_url: string; + + @Checkable.Optional(Checkable.String) + repurchase_correlation_id: string; + + @Checkable.Optional(Checkable.String) + receiver: string; + + static checked: (obj: any) => Contract; +} + + +export type PayCoinInfo = Array<{ updatedCoin: Coin, sig: CoinPaySig }>; + + +export namespace Amounts { + export interface Result { + amount: AmountJson; + // Was there an over-/underflow? + saturated: boolean; + } + + function getMaxAmount(currency: string): AmountJson { + return { + currency, + value: Number.MAX_SAFE_INTEGER, + fraction: 2**32, + } + } + + export function getZero(currency: string): AmountJson { + return { + currency, + value: 0, + fraction: 0, + } + } + + export function add(first: AmountJson, ...rest: AmountJson[]): Result { + let currency = first.currency; + let value = first.value + Math.floor(first.fraction / 1e6); + if (value > Number.MAX_SAFE_INTEGER) { + return {amount: getMaxAmount(currency), saturated: true}; + } + let fraction = first.fraction % 1e6; + for (let x of rest) { + if (x.currency !== currency) { + throw Error(`Mismatched currency: ${x.currency} and ${currency}`); + } + + value = value + x.value + Math.floor((fraction + x.fraction) / 1e6); + fraction = (fraction + x.fraction) % 1e6; + if (value > Number.MAX_SAFE_INTEGER) { + return {amount: getMaxAmount(currency), saturated: true}; + } + } + return {amount: {currency, value, fraction}, saturated: false}; + } + + + export function sub(a: AmountJson, b: AmountJson): Result { + if (a.currency !== b.currency) { + throw Error(`Mismatched currency: ${a.currency} and ${b.currency}`); + } + let currency = a.currency; + let value = a.value; + let fraction = a.fraction; + if (fraction < b.fraction) { + if (value < 1) { + return {amount: {currency, value: 0, fraction: 0}, saturated: true}; + } + value--; + fraction += 1e6; + } + console.assert(fraction >= b.fraction); + fraction -= b.fraction; + if (value < b.value) { + return {amount: {currency, value: 0, fraction: 0}, saturated: true}; + } + value -= b.value; + return {amount: {currency, value, fraction}, saturated: false}; + } + + export function cmp(a: AmountJson, b: AmountJson): number { + if (a.currency !== b.currency) { + throw Error(`Mismatched currency: ${a.currency} and ${b.currency}`); + } + let av = a.value + Math.floor(a.fraction / 1e6); + let af = a.fraction % 1e6; + let bv = b.value + Math.floor(b.fraction / 1e6); + let bf = b.fraction % 1e6; + switch (true) { + case av < bv: + return -1; + case av > bv: + return 1; + case af < bf: + return -1; + case af > bf: + return 1; + case af == bf: + return 0; + default: + throw Error("assertion failed"); + } + } + + export function copy(a: AmountJson): AmountJson { + return { + value: a.value, + fraction: a.fraction, + currency: a.currency, + } + } + + export function isNonZero(a: AmountJson) { + return a.value > 0 || a.fraction > 0; + } +} + + +export interface CheckRepurchaseResult { + isRepurchase: boolean; + existingContractHash?: string; + existingFulfillmentUrl?: string; +} + + +export interface Notifier { + notify(): void; +} diff --git a/lib/wallet/wallet.ts b/lib/wallet/wallet.ts new file mode 100644 index 000000000..45d083570 --- /dev/null +++ b/lib/wallet/wallet.ts @@ -0,0 +1,1156 @@ +/* + This file is part of TALER + (C) 2015 GNUnet e.V. + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * High-level wallet operations that should be indepentent from the underlying + * browser extension interface. + * @module Wallet + * @author Florian Dold + */ + +import { + AmountJson, + CreateReserveResponse, + IExchangeInfo, + Denomination, + Notifier, + WireInfo +} from "./types"; +import {HttpResponse, RequestException} from "./http"; +import {Query} from "./query"; +import {Checkable} from "./checkable"; +import {canonicalizeBaseUrl} from "./helpers"; +import {ReserveCreationInfo, Amounts} from "./types"; +import {PreCoin} from "./types"; +import {Reserve} from "./types"; +import {CryptoApi} from "./cryptoApi"; +import {Coin} from "./types"; +import {PayCoinInfo} from "./types"; +import {CheckRepurchaseResult} from "./types"; +import {Contract} from "./types"; +import {ExchangeHandle} from "./types"; + +"use strict"; + +export interface CoinWithDenom { + coin: Coin; + denom: Denomination; +} + +interface ReserveRecord { + reserve_pub: string; + reserve_priv: string, + exchange_base_url: string, + created: number, + last_query: number|null, + /** + * Current amount left in the reserve + */ + current_amount: AmountJson|null, + /** + * Amount requested when the reserve was created. + * When a reserve is re-used (rare!) the current_amount can + * be higher than the requested_amount + */ + requested_amount: AmountJson, + /** + * Amount we've already withdrawn from the reserve. + */ + withdrawn_amount: AmountJson; + confirmed: boolean, +} + + +@Checkable.Class +export class KeysJson { + @Checkable.List(Checkable.Value(Denomination)) + denoms: Denomination[]; + + @Checkable.String + master_public_key: string; + + @Checkable.Any + auditors: any[]; + + @Checkable.String + list_issue_date: string; + + @Checkable.Any + signkeys: any; + + @Checkable.String + eddsa_pub: string; + + @Checkable.String + eddsa_sig: string; + + static checked: (obj: any) => KeysJson; +} + + +@Checkable.Class +export class CreateReserveRequest { + /** + * The initial amount for the reserve. + */ + @Checkable.Value(AmountJson) + amount: AmountJson; + + /** + * Exchange URL where the bank should create the reserve. + */ + @Checkable.String + exchange: string; + + static checked: (obj: any) => CreateReserveRequest; +} + + +@Checkable.Class +export class ConfirmReserveRequest { + /** + * Public key of then reserve that should be marked + * as confirmed. + */ + @Checkable.String + reservePub: string; + + static checked: (obj: any) => ConfirmReserveRequest; +} + + +@Checkable.Class +export class Offer { + @Checkable.Value(Contract) + contract: Contract; + + @Checkable.String + merchant_sig: string; + + @Checkable.String + H_contract: string; + + static checked: (obj: any) => Offer; +} + +export interface HistoryRecord { + type: string; + timestamp: number; + subjectId?: string; + detail: any; + level: HistoryLevel; +} + + +interface ExchangeCoins { + [exchangeUrl: string]: CoinWithDenom[]; +} + + +interface Transaction { + contractHash: string; + contract: Contract; + payReq: any; + merchantSig: string; +} + +export enum HistoryLevel { + Trace = 1, + Developer = 2, + Expert = 3, + User = 4, +} + + +export interface Badge { + setText(s: string): void; + setColor(c: string): void; + startBusy(): void; + stopBusy(): void; +} + +export function canonicalJson(obj: any): string { + // Check for cycles, etc. + JSON.stringify(obj); + if (typeof obj == "string" || typeof obj == "number" || obj === null) { + return JSON.stringify(obj) + } + if (Array.isArray(obj)) { + let objs: string[] = obj.map((e) => canonicalJson(e)); + return `[${objs.join(',')}]`; + } + let keys: string[] = []; + for (let key in obj) { + keys.push(key); + } + keys.sort(); + let s = "{"; + for (let i = 0; i < keys.length; i++) { + let key = keys[i]; + s += JSON.stringify(key) + ":" + canonicalJson(obj[key]); + if (i != keys.length - 1) { + s += ","; + } + } + return s + "}"; +} + + +function deepEquals(x: any, y: any): boolean { + if (x === y) { + return true; + } + + if (Array.isArray(x) && x.length !== y.length) { + return false; + } + + var p = Object.keys(x); + return Object.keys(y).every((i) => p.indexOf(i) !== -1) && + p.every((i) => deepEquals(x[i], y[i])); +} + + +function flatMap<T, U>(xs: T[], f: (x: T) => U[]): U[] { + return xs.reduce((acc: U[], next: T) => [...f(next), ...acc], []); +} + + +function getTalerStampSec(stamp: string): number|null { + const m = stamp.match(/\/?Date\(([0-9]*)\)\/?/); + if (!m) { + return null; + } + return parseInt(m[1]); +} + + +function setTimeout(f: any, t: number) { + return chrome.extension.getBackgroundPage().setTimeout(f, t); +} + + +function isWithdrawableDenom(d: Denomination) { + const now_sec = (new Date).getTime() / 1000; + const stamp_withdraw_sec = getTalerStampSec(d.stamp_expire_withdraw); + // Withdraw if still possible to withdraw within a minute + if (stamp_withdraw_sec + 60 > now_sec) { + return true; + } + return false; +} + + +interface HttpRequestLibrary { + req(method: string, + url: string|uri.URI, + options?: any): Promise<HttpResponse>; + + get(url: string|uri.URI): Promise<HttpResponse>; + + postJson(url: string|uri.URI, body: any): Promise<HttpResponse>; + + postForm(url: string|uri.URI, form: any): Promise<HttpResponse>; +} + + +function copy(o: any) { + return JSON.parse(JSON.stringify(o)); +} + +/** + * Result of updating exisiting information + * about an exchange with a new '/keys' response. + */ +interface KeyUpdateInfo { + updatedExchangeInfo: IExchangeInfo; + addedDenominations: Denomination[]; + removedDenominations: Denomination[]; +} + + +/** + * Get a list of denominations (with repetitions possible) + * whose total value is as close as possible to the available + * amount, but never larger. + */ +function getWithdrawDenomList(amountAvailable: AmountJson, + denoms: Denomination[]): Denomination[] { + let remaining = Amounts.copy(amountAvailable); + const ds: Denomination[] = []; + + denoms = denoms.filter(isWithdrawableDenom); + denoms.sort((d1, d2) => Amounts.cmp(d2.value, d1.value)); + + // This is an arbitrary number of coins + // we can withdraw in one go. It's not clear if this limit + // is useful ... + for (let i = 0; i < 1000; i++) { + let found = false; + for (let d of denoms) { + let cost = Amounts.add(d.value, d.fee_withdraw).amount; + if (Amounts.cmp(remaining, cost) < 0) { + continue; + } + found = true; + remaining = Amounts.sub(remaining, cost).amount; + ds.push(d); + break; + } + if (!found) { + break; + } + } + return ds; +} + + +export class Wallet { + private db: IDBDatabase; + private http: HttpRequestLibrary; + private badge: Badge; + private notifier: Notifier; + public cryptoApi: CryptoApi; + + /** + * Set of identifiers for running operations. + */ + private runningOperations: Set<string> = new Set(); + + constructor(db: IDBDatabase, + http: HttpRequestLibrary, + badge: Badge, + notifier: Notifier) { + this.db = db; + this.http = http; + this.badge = badge; + this.notifier = notifier; + this.cryptoApi = new CryptoApi(); + + this.resumePendingFromDb(); + } + + + private startOperation(operationId: string) { + this.runningOperations.add(operationId); + this.badge.startBusy(); + } + + private stopOperation(operationId: string) { + this.runningOperations.delete(operationId); + if (this.runningOperations.size == 0) { + this.badge.stopBusy(); + } + } + + updateExchanges(): void { + console.log("updating exchanges"); + + Query(this.db) + .iter("exchanges") + .reduce((exchange: IExchangeInfo) => { + this.updateExchangeFromUrl(exchange.baseUrl) + .catch((e) => { + console.error("updating exchange failed", e); + }); + }); + } + + /** + * Resume various pending operations that are pending + * by looking at the database. + */ + private resumePendingFromDb(): void { + console.log("resuming pending operations from db"); + + Query(this.db) + .iter("reserves") + .reduce((reserve: any) => { + console.log("resuming reserve", reserve.reserve_pub); + this.processReserve(reserve); + }); + + Query(this.db) + .iter("precoins") + .reduce((preCoin: any) => { + console.log("resuming precoin"); + this.processPreCoin(preCoin); + }); + } + + + /** + * Get exchanges and associated coins that are still spendable, + * but only if the sum the coins' remaining value exceeds the payment amount. + */ + private async getPossibleExchangeCoins(paymentAmount: AmountJson, + depositFeeLimit: AmountJson, + allowedExchanges: ExchangeHandle[]): Promise<ExchangeCoins> { + // Mapping from exchange base URL to list of coins together with their + // denomination + let m: ExchangeCoins = {}; + + let x: number; + + function storeExchangeCoin(mc: any, url: string) { + let exchange: IExchangeInfo = mc[0]; + console.log("got coin for exchange", url); + let coin: Coin = mc[1]; + if (coin.suspended) { + console.log("skipping suspended coin", + coin.denomPub, + "from exchange", + exchange.baseUrl); + return; + } + let denom = exchange.active_denoms.find((e) => e.denom_pub === coin.denomPub); + if (!denom) { + console.warn("denom not found (database inconsistent)"); + return; + } + if (denom.value.currency !== paymentAmount.currency) { + console.warn("same pubkey for different currencies"); + return; + } + let cd = {coin, denom}; + let x = m[url]; + if (!x) { + m[url] = [cd]; + } else { + x.push(cd); + } + } + + // Make sure that we don't look up coins + // for the same URL twice ... + let handledExchanges = new Set(); + + let ps = flatMap(allowedExchanges, (info: ExchangeHandle) => { + if (handledExchanges.has(info.url)) { + return []; + } + handledExchanges.add(info.url); + console.log("Checking for merchant's exchange", JSON.stringify(info)); + return [ + Query(this.db) + .iter("exchanges", {indexName: "pubKey", only: info.master_pub}) + .indexJoin("coins", "exchangeBaseUrl", (exchange) => exchange.baseUrl) + .reduce((x) => storeExchangeCoin(x, info.url)) + ]; + }); + + await Promise.all(ps); + + let ret: ExchangeCoins = {}; + + if (Object.keys(m).length == 0) { + console.log("not suitable exchanges found"); + } + + console.dir(m); + + // We try to find the first exchange where we have + // enough coins to cover the paymentAmount with fees + // under depositFeeLimit + + nextExchange: + for (let key in m) { + let coins = m[key]; + // Sort by ascending deposit fee + coins.sort((o1, o2) => Amounts.cmp(o1.denom.fee_deposit, + o2.denom.fee_deposit)); + let maxFee = Amounts.copy(depositFeeLimit); + let minAmount = Amounts.copy(paymentAmount); + let accFee = Amounts.copy(coins[0].denom.fee_deposit); + let accAmount = Amounts.getZero(coins[0].coin.currentAmount.currency); + let usableCoins: CoinWithDenom[] = []; + nextCoin: + for (let i = 0; i < coins.length; i++) { + let coinAmount = Amounts.copy(coins[i].coin.currentAmount); + let coinFee = coins[i].denom.fee_deposit; + if (Amounts.cmp(coinAmount, coinFee) <= 0) { + continue nextCoin; + } + accFee = Amounts.add(accFee, coinFee).amount; + accAmount = Amounts.add(accAmount, coinAmount).amount; + if (Amounts.cmp(accFee, maxFee) >= 0) { + // FIXME: if the fees are too high, we have + // to cover them ourselves .... + console.log("too much fees"); + continue nextExchange; + } + usableCoins.push(coins[i]); + if (Amounts.cmp(accAmount, minAmount) >= 0) { + ret[key] = usableCoins; + continue nextExchange; + } + } + } + return ret; + } + + + /** + * Record all information that is necessary to + * pay for a contract in the wallet's database. + */ + private async recordConfirmPay(offer: Offer, + payCoinInfo: PayCoinInfo, + chosenExchange: string): Promise<void> { + let payReq: any = {}; + payReq["amount"] = offer.contract.amount; + payReq["coins"] = payCoinInfo.map((x) => x.sig); + payReq["H_contract"] = offer.H_contract; + payReq["max_fee"] = offer.contract.max_fee; + payReq["merchant_sig"] = offer.merchant_sig; + payReq["exchange"] = URI(chosenExchange).href(); + payReq["refund_deadline"] = offer.contract.refund_deadline; + payReq["timestamp"] = offer.contract.timestamp; + payReq["transaction_id"] = offer.contract.transaction_id; + let t: Transaction = { + contractHash: offer.H_contract, + contract: offer.contract, + payReq: payReq, + merchantSig: offer.merchant_sig, + }; + + let historyEntry = { + type: "pay", + timestamp: (new Date).getTime(), + subjectId: `contract-${offer.H_contract}`, + detail: { + merchantName: offer.contract.merchant.name, + amount: offer.contract.amount, + contractHash: offer.H_contract, + fulfillmentUrl: offer.contract.fulfillment_url + } + }; + + await Query(this.db) + .put("transactions", t) + .put("history", historyEntry) + .putAll("coins", payCoinInfo.map((pci) => pci.updatedCoin)) + .finish(); + + this.notifier.notify(); + } + + + async putHistory(historyEntry: HistoryRecord): Promise<void> { + await Query(this.db).put("history", historyEntry).finish(); + this.notifier.notify(); + } + + + /** + * Add a contract to the wallet and sign coins, + * but do not send them yet. + */ + async confirmPay(offer: Offer): Promise<any> { + console.log("executing confirmPay"); + + let transaction = await Query(this.db) + .get("transactions", offer.H_contract); + + if (transaction) { + // Already payed ... + return {}; + } + + let mcs = await this.getPossibleExchangeCoins(offer.contract.amount, + offer.contract.max_fee, + offer.contract.exchanges); + + if (Object.keys(mcs).length == 0) { + console.log("not confirming payment, insufficient coins"); + return { + error: "coins-insufficient", + }; + } + let exchangeUrl = Object.keys(mcs)[0]; + + let ds = await this.cryptoApi.signDeposit(offer, mcs[exchangeUrl]); + await this.recordConfirmPay(offer, + ds, + exchangeUrl); + return {}; + } + + + /** + * Add a contract to the wallet and sign coins, + * but do not send them yet. + */ + async checkPay(offer: Offer): Promise<any> { + // First check if we already payed for it. + let transaction = await + Query(this.db) + .get("transactions", offer.H_contract); + if (transaction) { + return {isPayed: true}; + } + + // If not already payed, check if we could pay for it. + let mcs = await this.getPossibleExchangeCoins(offer.contract.amount, + offer.contract.max_fee, + offer.contract.exchanges); + + if (Object.keys(mcs).length == 0) { + console.log("not confirming payment, insufficient coins"); + return { + error: "coins-insufficient", + }; + } + return {isPayed: false}; + } + + + /** + * Retrieve all necessary information for looking up the contract + * with the given hash. + */ + async executePayment(H_contract: string): Promise<any> { + let t = await Query(this.db) + .get("transactions", H_contract); + if (!t) { + return { + success: false, + contractFound: false, + } + } + let resp = { + success: true, + payReq: t.payReq, + contract: t.contract, + }; + return resp; + } + + + /** + * First fetch information requred to withdraw from the reserve, + * then deplete the reserve, withdrawing coins until it is empty. + */ + private async processReserve(reserveRecord: ReserveRecord, + retryDelayMs: number = 250): Promise<void> { + const opId = "reserve-" + reserveRecord.reserve_pub; + this.startOperation(opId); + + try { + let exchange = await this.updateExchangeFromUrl(reserveRecord.exchange_base_url); + let reserve = await this.updateReserve(reserveRecord.reserve_pub, + exchange); + let n = await this.depleteReserve(reserve, exchange); + + if (n != 0) { + let depleted = { + type: "depleted-reserve", + subjectId: `reserve-progress-${reserveRecord.reserve_pub}`, + timestamp: (new Date).getTime(), + detail: { + reservePub: reserveRecord.reserve_pub, + requestedAmount: reserveRecord.requested_amount, + currentAmount: reserveRecord.current_amount, + } + }; + await Query(this.db).put("history", depleted).finish(); + } + } catch (e) { + // random, exponential backoff truncated at 3 minutes + let nextDelay = Math.min(2 * retryDelayMs + retryDelayMs * Math.random(), + 3000 * 60); + console.warn(`Failed to deplete reserve, trying again in ${retryDelayMs} ms`); + setTimeout(() => this.processReserve(reserveRecord, nextDelay), + retryDelayMs); + } finally { + this.stopOperation(opId); + } + } + + + private async processPreCoin(preCoin: PreCoin, + retryDelayMs = 100): Promise<void> { + try { + const coin = await this.withdrawExecute(preCoin); + this.storeCoin(coin); + } catch (e) { + console.error("Failed to withdraw coin from precoin, retrying in", + retryDelayMs, + "ms", e); + // exponential backoff truncated at one minute + let nextRetryDelayMs = Math.min(retryDelayMs * 2, 1000 * 60); + setTimeout(() => this.processPreCoin(preCoin, nextRetryDelayMs), + retryDelayMs); + } + } + + + /** + * Create a reserve, but do not flag it as confirmed yet. + */ + async createReserve(req: CreateReserveRequest): Promise<CreateReserveResponse> { + let keypair = await this.cryptoApi.createEddsaKeypair(); + const now = (new Date).getTime(); + const canonExchange = canonicalizeBaseUrl(req.exchange); + + const reserveRecord: ReserveRecord = { + reserve_pub: keypair.pub, + reserve_priv: keypair.priv, + exchange_base_url: canonExchange, + created: now, + last_query: null, + current_amount: null, + requested_amount: req.amount, + confirmed: false, + withdrawn_amount: Amounts.getZero(req.amount.currency) + }; + + const historyEntry = { + type: "create-reserve", + timestamp: now, + subjectId: `reserve-progress-${reserveRecord.reserve_pub}`, + detail: { + requestedAmount: req.amount, + reservePub: reserveRecord.reserve_pub, + } + }; + + await Query(this.db) + .put("reserves", reserveRecord) + .put("history", historyEntry) + .finish(); + + let r: CreateReserveResponse = { + exchange: canonExchange, + reservePub: keypair.pub, + }; + return r; + } + + + /** + * Mark an existing reserve as confirmed. The wallet will start trying + * to withdraw from that reserve. This may not immediately succeed, + * since the exchange might not know about the reserve yet, even though the + * bank confirmed its creation. + * + * A confirmed reserve should be shown to the user in the UI, while + * an unconfirmed reserve should be hidden. + */ + async confirmReserve(req: ConfirmReserveRequest): Promise<void> { + const now = (new Date).getTime(); + let reserve: ReserveRecord = await Query(this.db) + .get("reserves", req.reservePub); + const historyEntry = { + type: "confirm-reserve", + timestamp: now, + subjectId: `reserve-progress-${reserve.reserve_pub}`, + detail: { + reservePub: req.reservePub, + requestedAmount: reserve.requested_amount, + } + }; + if (!reserve) { + console.error("Unable to confirm reserve, not found in DB"); + return; + } + reserve.confirmed = true; + await Query(this.db) + .put("reserves", reserve) + .put("history", historyEntry) + .finish(); + + this.processReserve(reserve); + } + + + private async withdrawExecute(pc: PreCoin): Promise<Coin> { + let reserve = await Query(this.db) + .get("reserves", pc.reservePub); + + let wd: any = {}; + wd.denom_pub = pc.denomPub; + wd.reserve_pub = pc.reservePub; + wd.reserve_sig = pc.withdrawSig; + wd.coin_ev = pc.coinEv; + let reqUrl = URI("reserve/withdraw").absoluteTo(reserve.exchange_base_url); + let resp = await this.http.postJson(reqUrl, wd); + + + if (resp.status != 200) { + throw new RequestException({ + hint: "Withdrawal failed", + status: resp.status + }); + } + let r = JSON.parse(resp.responseText); + let denomSig = await this.cryptoApi.rsaUnblind(r.ev_sig, + pc.blindingKey, + pc.denomPub); + let coin: Coin = { + coinPub: pc.coinPub, + coinPriv: pc.coinPriv, + denomPub: pc.denomPub, + denomSig: denomSig, + currentAmount: pc.coinValue, + exchangeBaseUrl: pc.exchangeBaseUrl, + }; + return coin; + } + + async storeCoin(coin: Coin): Promise<void> { + console.log("storing coin", new Date()); + + let historyEntry: HistoryRecord = { + type: "withdraw", + timestamp: (new Date).getTime(), + level: HistoryLevel.Expert, + detail: { + coinPub: coin.coinPub, + } + }; + await Query(this.db) + .delete("precoins", coin.coinPub) + .add("coins", coin) + .add("history", historyEntry) + .finish(); + this.notifier.notify(); + } + + + /** + * Withdraw one coin of the given denomination from the given reserve. + */ + private async withdraw(denom: Denomination, reserve: Reserve): Promise<void> { + console.log("creating pre coin at", new Date()); + let preCoin = await this.cryptoApi + .createPreCoin(denom, reserve); + await Query(this.db) + .put("precoins", preCoin) + .finish(); + await this.processPreCoin(preCoin); + } + + + /** + * Withdraw coins from a reserve until it is empty. + */ + private async depleteReserve(reserve: any, + exchange: IExchangeInfo): Promise<number> { + let denomsAvailable: Denomination[] = copy(exchange.active_denoms); + let denomsForWithdraw = getWithdrawDenomList(reserve.current_amount, + denomsAvailable); + + let ps = denomsForWithdraw.map((denom) => this.withdraw(denom, reserve)); + await Promise.all(ps); + return ps.length; + } + + + /** + * Update the information about a reserve that is stored in the wallet + * by quering the reserve's exchange. + */ + private async updateReserve(reservePub: string, + exchange: IExchangeInfo): Promise<Reserve> { + let reserve = await Query(this.db) + .get("reserves", reservePub); + let reqUrl = URI("reserve/status").absoluteTo(exchange.baseUrl); + reqUrl.query({'reserve_pub': reservePub}); + let resp = await this.http.get(reqUrl); + if (resp.status != 200) { + throw Error(); + } + let reserveInfo = JSON.parse(resp.responseText); + if (!reserveInfo) { + throw Error(); + } + let oldAmount = reserve.current_amount; + let newAmount = reserveInfo.balance; + reserve.current_amount = reserveInfo.balance; + let historyEntry = { + type: "reserve-update", + timestamp: (new Date).getTime(), + subjectId: `reserve-progress-${reserve.reserve_pub}`, + detail: { + reservePub, + requestedAmount: reserve.requested_amount, + oldAmount, + newAmount + } + }; + await Query(this.db) + .put("reserves", reserve) + .finish(); + return reserve; + } + + + /** + * Get the wire information for the exchange with the given base URL. + */ + async getWireInfo(exchangeBaseUrl: string): Promise<WireInfo> { + exchangeBaseUrl = canonicalizeBaseUrl(exchangeBaseUrl); + let reqUrl = URI("wire").absoluteTo(exchangeBaseUrl); + let resp = await this.http.get(reqUrl); + + if (resp.status != 200) { + throw Error("/wire request failed"); + } + + let wiJson = JSON.parse(resp.responseText); + if (!wiJson) { + throw Error("/wire response malformed") + } + return wiJson; + } + + async getReserveCreationInfo(baseUrl: string, + amount: AmountJson): Promise<ReserveCreationInfo> { + let exchangeInfo = await this.updateExchangeFromUrl(baseUrl); + + let selectedDenoms = getWithdrawDenomList(amount, + exchangeInfo.active_denoms); + let acc = Amounts.getZero(amount.currency); + for (let d of selectedDenoms) { + acc = Amounts.add(acc, d.fee_withdraw).amount; + } + let actualCoinCost = selectedDenoms + .map((d: Denomination) => Amounts.add(d.value, + d.fee_withdraw).amount) + .reduce((a, b) => Amounts.add(a, b).amount); + + let wireInfo = await this.getWireInfo(baseUrl); + + let ret: ReserveCreationInfo = { + exchangeInfo, + selectedDenoms, + wireInfo, + withdrawFee: acc, + overhead: Amounts.sub(amount, actualCoinCost).amount, + }; + return ret; + } + + + /** + * Update or add exchange DB entry by fetching the /keys information. + * Optionally link the reserve entry to the new or existing + * exchange entry in then DB. + */ + async updateExchangeFromUrl(baseUrl: string): Promise<IExchangeInfo> { + baseUrl = canonicalizeBaseUrl(baseUrl); + let reqUrl = URI("keys").absoluteTo(baseUrl); + let resp = await this.http.get(reqUrl); + if (resp.status != 200) { + throw Error("/keys request failed"); + } + let exchangeKeysJson = KeysJson.checked(JSON.parse(resp.responseText)); + return this.updateExchangeFromJson(baseUrl, exchangeKeysJson); + } + + private async suspendCoins(exchangeInfo: IExchangeInfo): Promise<void> { + let suspendedCoins = await Query(this.db) + .iter("coins", + {indexName: "exchangeBaseUrl", only: exchangeInfo.baseUrl}) + .reduce((coin: Coin, suspendedCoins: Coin[]) => { + if (!exchangeInfo.active_denoms.find((c) => c.denom_pub == coin.denomPub)) { + return Array.prototype.concat(suspendedCoins, [coin]); + } + return Array.prototype.concat(suspendedCoins); + }, []); + + let q = Query(this.db); + suspendedCoins.map((c) => { + console.log("suspending coin", c); + c.suspended = true; + q.put("coins", c); + }); + await q.finish(); + } + + + private async updateExchangeFromJson(baseUrl: string, + exchangeKeysJson: KeysJson): Promise<IExchangeInfo> { + const updateTimeSec = getTalerStampSec(exchangeKeysJson.list_issue_date); + if (updateTimeSec === null) { + throw Error("invalid update time"); + } + + let r = await Query(this.db).get("exchanges", baseUrl); + + let exchangeInfo: IExchangeInfo; + + if (!r) { + exchangeInfo = { + baseUrl, + all_denoms: [], + active_denoms: [], + last_update_time: updateTimeSec, + masterPublicKey: exchangeKeysJson.master_public_key, + }; + console.log("making fresh exchange"); + } else { + if (updateTimeSec < r.last_update_time) { + console.log("outdated /keys, not updating"); + return r + } + exchangeInfo = r; + console.log("updating old exchange"); + } + + let updatedExchangeInfo = await this.updateExchangeInfo(exchangeInfo, + exchangeKeysJson); + await this.suspendCoins(updatedExchangeInfo); + + await Query(this.db) + .put("exchanges", updatedExchangeInfo) + .finish(); + + return updatedExchangeInfo; + } + + + private async updateExchangeInfo(exchangeInfo: IExchangeInfo, + newKeys: KeysJson): Promise<IExchangeInfo> { + if (exchangeInfo.masterPublicKey != newKeys.master_public_key) { + throw Error("public keys do not match"); + } + + exchangeInfo.active_denoms = []; + + let denomsToCheck = newKeys.denoms.filter((newDenom) => { + // did we find the new denom in the list of all (old) denoms? + let found = false; + for (let oldDenom of exchangeInfo.all_denoms) { + if (oldDenom.denom_pub === newDenom.denom_pub) { + let a: any = Object.assign({}, oldDenom); + let b: any = Object.assign({}, newDenom); + // pub hash is only there for convenience in the wallet + delete a["pub_hash"]; + delete b["pub_hash"]; + if (!deepEquals(a, b)) { + console.error("denomination parameters were modified, old/new:"); + console.dir(a); + console.dir(b); + // FIXME: report to auditors + } + found = true; + break; + } + } + + if (found) { + exchangeInfo.active_denoms.push(newDenom); + // No need to check signatures + return false; + } + return true; + }); + + let ps = denomsToCheck.map(async(denom) => { + let valid = await this.cryptoApi + .isValidDenom(denom, + exchangeInfo.masterPublicKey); + if (!valid) { + console.error("invalid denomination", + denom, + "with key", + exchangeInfo.masterPublicKey); + // FIXME: report to auditors + } + exchangeInfo.active_denoms.push(denom); + exchangeInfo.all_denoms.push(denom); + }); + + await Promise.all(ps); + + return exchangeInfo; + } + + + /** + * Retrieve a mapping from currency name to the amount + * that is currenctly available for spending in the wallet. + */ + async getBalances(): Promise<any> { + function collectBalances(c: Coin, byCurrency: any) { + if (c.suspended) { + return byCurrency; + } + let acc: AmountJson = byCurrency[c.currentAmount.currency]; + if (!acc) { + acc = Amounts.getZero(c.currentAmount.currency); + } + byCurrency[c.currentAmount.currency] = Amounts.add(c.currentAmount, + acc).amount; + return byCurrency; + } + + let byCurrency = await Query(this.db) + .iter("coins") + .reduce(collectBalances, {}); + + return {balances: byCurrency}; + } + + + /** + * Retrive the full event history for this wallet. + */ + async getHistory(): Promise<any> { + function collect(x: any, acc: any) { + acc.push(x); + return acc; + } + + let history = await + Query(this.db) + .iter("history", {indexName: "timestamp"}) + .reduce(collect, []); + + return {history}; + } + + async hashContract(contract: any): Promise<string> { + return this.cryptoApi.hashString(canonicalJson(contract)); + } + + /** + * Check if there's an equivalent contract we've already purchased. + */ + async checkRepurchase(contract: Contract): Promise<CheckRepurchaseResult> { + if (!contract.repurchase_correlation_id) { + console.log("no repurchase: no correlation id"); + return {isRepurchase: false}; + } + let result: Transaction = await Query(this.db) + .getIndexed("transactions", + "repurchase", + [contract.merchant_pub, contract.repurchase_correlation_id]); + + if (result) { + console.assert(result.contract.repurchase_correlation_id == contract.repurchase_correlation_id); + return { + isRepurchase: true, + existingContractHash: result.contractHash, + existingFulfillmentUrl: result.contract.fulfillment_url, + }; + } else { + return {isRepurchase: false}; + } + } +}
\ No newline at end of file diff --git a/lib/wallet/wxApi.ts b/lib/wallet/wxApi.ts new file mode 100644 index 000000000..84235c6a9 --- /dev/null +++ b/lib/wallet/wxApi.ts @@ -0,0 +1,41 @@ +/* + This file is part of TALER + (C) 2016 GNUnet e.V. + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import {AmountJson} from "./types"; +import {ReserveCreationInfo} from "./types"; + +/** + * Interface to the wallet through WebExtension messaging. + * @author Florian Dold + */ + + +export function getReserveCreationInfo(baseUrl: string, + amount: AmountJson): Promise<ReserveCreationInfo> { + let m = {type: "reserve-creation-info", detail: {baseUrl, amount}}; + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage(m, (resp) => { + if (resp.error) { + console.error("error response", resp); + let e = Error("call to reserve-creation-info failed"); + (e as any).errorResponse = resp; + reject(e); + return; + } + resolve(resp); + }); + }); +} diff --git a/lib/wallet/wxMessaging.ts b/lib/wallet/wxMessaging.ts new file mode 100644 index 000000000..5c97248c4 --- /dev/null +++ b/lib/wallet/wxMessaging.ts @@ -0,0 +1,392 @@ +/* + This file is part of TALER + (C) 2016 GNUnet e.V. + + TALER is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + TALER is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + + +import { + Wallet, + Offer, + Badge, + ConfirmReserveRequest, + CreateReserveRequest +} from "./wallet"; +import {deleteDb, exportDb, openTalerDb} from "./db"; +import {BrowserHttpLib} from "./http"; +import {Checkable} from "./checkable"; +import {AmountJson} from "./types"; +import Port = chrome.runtime.Port; +import {Notifier} from "./types"; +import {Contract} from "./types"; +import MessageSender = chrome.runtime.MessageSender; +import {ChromeBadge} from "./chromeBadge"; + +"use strict"; + +/** + * Messaging for the WebExtensions wallet. Should contain + * parts that are specific for WebExtensions, but as little business + * logic as possible. + * + * @author Florian Dold + */ + + +type Handler = (detail: any, sender: MessageSender) => Promise<any>; + +function makeHandlers(db: IDBDatabase, + wallet: Wallet): {[msg: string]: Handler} { + return { + ["balances"]: function(detail, sender) { + return wallet.getBalances(); + }, + ["dump-db"]: function(detail, sender) { + return exportDb(db); + }, + ["ping"]: function(detail, sender) { + if (!sender || !sender.tab || !sender.tab.id) { + return Promise.resolve(); + } + let id: number = sender.tab.id; + let info: any = <any>paymentRequestCookies[id]; + delete paymentRequestCookies[id]; + return Promise.resolve(info); + }, + ["reset"]: function(detail, sender) { + if (db) { + let tx = db.transaction(Array.from(db.objectStoreNames), 'readwrite'); + for (let i = 0; i < db.objectStoreNames.length; i++) { + tx.objectStore(db.objectStoreNames[i]).clear(); + } + } + deleteDb(); + + chrome.browserAction.setBadgeText({text: ""}); + console.log("reset done"); + // Response is synchronous + return Promise.resolve({}); + }, + ["create-reserve"]: function(detail, sender) { + const d = { + exchange: detail.exchange, + amount: detail.amount, + }; + const req = CreateReserveRequest.checked(d); + return wallet.createReserve(req); + }, + ["confirm-reserve"]: function(detail, sender) { + // TODO: make it a checkable + const d = { + reservePub: detail.reservePub + }; + const req = ConfirmReserveRequest.checked(d); + return wallet.confirmReserve(req); + }, + ["confirm-pay"]: function(detail, sender) { + let offer: Offer; + try { + offer = Offer.checked(detail.offer); + } catch (e) { + if (e instanceof Checkable.SchemaError) { + console.error("schema error:", e.message); + return Promise.resolve({ + error: "invalid contract", + hint: e.message, + detail: detail + }); + } else { + throw e; + } + } + + return wallet.confirmPay(offer); + }, + ["check-pay"]: function(detail, sender) { + let offer: Offer; + try { + offer = Offer.checked(detail.offer); + } catch (e) { + if (e instanceof Checkable.SchemaError) { + console.error("schema error:", e.message); + return Promise.resolve({ + error: "invalid contract", + hint: e.message, + detail: detail + }); + } else { + throw e; + } + } + return wallet.checkPay(offer); + }, + ["execute-payment"]: function(detail: any, sender: MessageSender) { + if (sender.tab && sender.tab.id) { + rateLimitCache[sender.tab.id]++; + if (rateLimitCache[sender.tab.id] > 10) { + console.warn("rate limit for execute payment exceeded"); + let msg = { + error: "rate limit exceeded for execute-payment", + rateLimitExceeded: true, + hint: "Check for redirect loops", + }; + return Promise.resolve(msg); + } + } + return wallet.executePayment(detail.H_contract); + }, + ["exchange-info"]: function(detail) { + if (!detail.baseUrl) { + return Promise.resolve({error: "bad url"}); + } + return wallet.updateExchangeFromUrl(detail.baseUrl); + }, + ["hash-contract"]: function(detail) { + if (!detail.contract) { + return Promise.resolve({error: "contract missing"}); + } + return wallet.hashContract(detail.contract).then((hash) => { + return {hash}; + }); + }, + ["put-history-entry"]: function(detail: any) { + if (!detail.historyEntry) { + return Promise.resolve({error: "historyEntry missing"}); + } + return wallet.putHistory(detail.historyEntry); + }, + ["reserve-creation-info"]: function(detail, sender) { + if (!detail.baseUrl || typeof detail.baseUrl !== "string") { + return Promise.resolve({error: "bad url"}); + } + let amount = AmountJson.checked(detail.amount); + return wallet.getReserveCreationInfo(detail.baseUrl, amount); + }, + ["check-repurchase"]: function(detail, sender) { + let contract = Contract.checked(detail.contract); + return wallet.checkRepurchase(contract); + }, + ["get-history"]: function(detail, sender) { + // TODO: limit history length + return wallet.getHistory(); + }, + ["payment-failed"]: function(detail, sender) { + // For now we just update exchanges (maybe the exchange did something + // wrong and the keys were messed up). + // FIXME: in the future we should look at what actually went wrong. + console.error("payment reported as failed"); + wallet.updateExchanges(); + return Promise.resolve(); + }, + }; +} + + +function dispatch(handlers: any, req: any, sender: any, sendResponse: any) { + if (req.type in handlers) { + Promise + .resolve() + .then(() => { + const p = handlers[req.type](req.detail, sender); + + return p.then((r: any) => { + try { + sendResponse(r); + } catch (e) { + // might fail if tab disconnected + } + }) + }) + .catch((e) => { + console.log(`exception during wallet handler for '${req.type}'`); + console.log("request", req); + console.error(e); + try { + sendResponse({ + error: "exception", + hint: e.message, + stack: e.stack.toString() + }); + + } catch (e) { + // might fail if tab disconnected + } + }); + // The sendResponse call is async + return true; + } else { + console.error(`Request type ${JSON.stringify(req)} unknown, req ${req.type}`); + try { + sendResponse({error: "request unknown"}); + } catch (e) { + // might fail if tab disconnected + } + + // The sendResponse call is sync + return false; + } +} + +class ChromeNotifier implements Notifier { + ports: Port[] = []; + + constructor() { + chrome.runtime.onConnect.addListener((port) => { + console.log("got connect!"); + this.ports.push(port); + port.onDisconnect.addListener(() => { + let i = this.ports.indexOf(port); + if (i >= 0) { + this.ports.splice(i, 1); + } else { + console.error("port already removed"); + } + }); + }); + } + + notify() { + console.log("notifying all ports"); + for (let p of this.ports) { + p.postMessage({notify: true}); + } + } +} + + +/** + * Mapping from tab ID to payment information (if any). + */ +let paymentRequestCookies: {[n: number]: any} = {}; + +function handleHttpPayment(headerList: chrome.webRequest.HttpHeader[], + url: string, tabId: number): any { + const headers: {[s: string]: string} = {}; + for (let kv of headerList) { + if (kv.value) { + headers[kv.name.toLowerCase()] = kv.value; + } + } + + const contractUrl = headers["x-taler-contract-url"]; + if (contractUrl !== undefined) { + paymentRequestCookies[tabId] = {type: "fetch", contractUrl}; + return; + } + + const contractHash = headers["x-taler-contract-hash"]; + + if (contractHash !== undefined) { + const payUrl = headers["x-taler-pay-url"]; + if (payUrl === undefined) { + console.log("malformed 402, X-Taler-Pay-Url missing"); + return; + } + + // Offer URL is optional + const offerUrl = headers["x-taler-offer-url"]; + paymentRequestCookies[tabId] = { + type: "execute", + offerUrl, + payUrl, + contractHash + }; + return; + } + + // looks like it's not a taler request, it might be + // for a different payment system (or the shop is buggy) + console.log("ignoring non-taler 402 response"); +} + +// Useful for debugging ... +export let wallet: Wallet|undefined = undefined; +export let badge: ChromeBadge|undefined = undefined; + +// Rate limit cache for executePayment operations, to break redirect loops +let rateLimitCache: {[n: number]: number} = {}; + +function clearRateLimitCache() { + rateLimitCache = {}; +} + +export function wxMain() { + chrome.browserAction.setBadgeText({text: ""}); + badge = new ChromeBadge(); + + chrome.tabs.query({}, function(tabs) { + for (let tab of tabs) { + if (!tab.url || !tab.id) { + return; + } + let uri = URI(tab.url); + if (uri.protocol() == "http" || uri.protocol() == "https") { + console.log("injecting into existing tab", tab.id); + chrome.tabs.executeScript(tab.id, {file: "lib/vendor/URI.js"}); + chrome.tabs.executeScript(tab.id, {file: "lib/taler-wallet-lib.js"}); + chrome.tabs.executeScript(tab.id, {file: "content_scripts/notify.js"}); + } + } + }); + + chrome.extension.getBackgroundPage().setInterval(clearRateLimitCache, 5000); + + Promise.resolve() + .then(() => { + return openTalerDb(); + }) + .catch((e) => { + console.error("could not open database"); + console.error(e); + }) + .then((db: IDBDatabase) => { + let http = new BrowserHttpLib(); + let notifier = new ChromeNotifier(); + console.log("setting wallet"); + wallet = new Wallet(db, http, badge!, notifier); + + // Handlers for messages coming directly from the content + // script on the page + let handlers = makeHandlers(db, wallet!); + chrome.runtime.onMessage.addListener((req, sender, sendResponse) => { + try { + return dispatch(handlers, req, sender, sendResponse) + } catch (e) { + console.log(`exception during wallet handler (dispatch)`); + console.log("request", req); + console.error(e); + sendResponse({ + error: "exception", + hint: e.message, + stack: e.stack.toString() + }); + return false; + } + }); + + // Handlers for catching HTTP requests + chrome.webRequest.onHeadersReceived.addListener((details) => { + if (details.statusCode != 402) { + return; + } + console.log(`got 402 from ${details.url}`); + return handleHttpPayment(details.responseHeaders || [], + details.url, + details.tabId); + }, {urls: ["<all_urls>"]}, ["responseHeaders", "blocking"]); + }) + .catch((e) => { + console.error("could not initialize wallet messaging"); + console.error(e); + }); +}
\ No newline at end of file |