diff options
Diffstat (limited to 'lib/wallet')
-rw-r--r-- | lib/wallet/checkable.ts | 241 | ||||
-rw-r--r-- | lib/wallet/cryptoApi.ts | 93 | ||||
-rw-r--r-- | lib/wallet/cryptoLib.ts | 222 | ||||
-rw-r--r-- | lib/wallet/cryptoWorker.ts | 65 | ||||
-rw-r--r-- | lib/wallet/db.ts | 109 | ||||
-rw-r--r-- | lib/wallet/emscriptif.ts | 1006 | ||||
-rw-r--r-- | lib/wallet/helpers.ts | 65 | ||||
-rw-r--r-- | lib/wallet/http.ts | 84 | ||||
-rw-r--r-- | lib/wallet/query.ts | 360 | ||||
-rw-r--r-- | lib/wallet/types.ts | 269 | ||||
-rw-r--r-- | lib/wallet/wallet.ts | 957 | ||||
-rw-r--r-- | lib/wallet/wxApi.ts | 40 | ||||
-rw-r--r-- | lib/wallet/wxMessaging.ts | 230 |
13 files changed, 3741 insertions, 0 deletions
diff --git a/lib/wallet/checkable.ts b/lib/wallet/checkable.ts new file mode 100644 index 000000000..27ea9bf74 --- /dev/null +++ b/lib/wallet/checkable.ts @@ -0,0 +1,241 @@ +/* + 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, 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 { + export function SchemaError(message) { + this.name = 'SchemaError'; + this.message = message; + this.stack = (<any>new Error()).stack; + } + + SchemaError.prototype = new Error; + + let chkSym = Symbol("checkable"); + + + function checkNumber(target, prop, path): any { + if ((typeof target) !== "number") { + throw new SchemaError(`expected number for ${path}`); + } + return target; + } + + + function checkString(target, prop, path): any { + if (typeof target !== "string") { + throw new SchemaError(`expected string for ${path}, got ${typeof target} instead`); + } + return target; + } + + + function checkAnyObject(target, prop, path): any { + if (typeof target !== "object") { + throw new SchemaError(`expected (any) object for ${path}, got ${typeof target} instead`); + } + return target; + } + + + function checkAny(target, prop, path): any { + return target; + } + + + function checkList(target, prop, 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, prop, path): any { + console.assert(prop.propertyKey); + prop.elementChecker(target, + prop.elementProp, + path.concat([prop.propertyKey])); + return target; + } + + + function checkValue(target, prop, 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) { + target.checked = (v) => { + return checkValue(v, { + propertyKey: "(root)", + type: target, + checker: checkValue + }, ["(root)"]); + }; + return target; + } + + + export function Value(type) { + 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) { + 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) { + 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) { + let chk = target[chkSym]; + if (!chk) { + chk = {props: []}; + target[chkSym] = chk; + } + return chk; + } +}
\ No newline at end of file diff --git a/lib/wallet/cryptoApi.ts b/lib/wallet/cryptoApi.ts new file mode 100644 index 000000000..300b928db --- /dev/null +++ b/lib/wallet/cryptoApi.ts @@ -0,0 +1,93 @@ +/* + 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, If not, see <http://www.gnu.org/licenses/> + */ + + +import {PreCoin} from "./types"; +import {Reserve} from "./types"; +import {Denomination} from "./types"; +import {Offer} from "./wallet"; +import {CoinWithDenom} from "./wallet"; +import {PayCoinInfo} from "./types"; +export class CryptoApi { + private nextRpcId: number = 1; + private rpcRegistry = {}; + private cryptoWorker: Worker; + + + constructor() { + this.cryptoWorker = new Worker("/lib/wallet/cryptoWorker.js"); + + this.cryptoWorker.onmessage = (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, reject} = this.rpcRegistry[id]; + resolve(msg.data.result); + } + } + + + private registerRpcId(resolve, reject): number { + let id = this.nextRpcId++; + this.rpcRegistry[id] = {resolve, reject}; + return id; + } + + + private doRpc<T>(methodName: string, ...args): Promise<T> { + return new Promise<T>((resolve, reject) => { + let msg = { + operation: methodName, + id: this.registerRpcId(resolve, reject), + args: args, + }; + this.cryptoWorker.postMessage(msg); + }); + } + + + createPreCoin(denom: Denomination, reserve: Reserve): Promise<PreCoin> { + return this.doRpc("createPreCoin", denom, reserve); + } + + hashRsaPub(rsaPub: string): Promise<string> { + return this.doRpc("hashRsaPub", rsaPub); + } + + isValidDenom(denom: Denomination, + masterPub: string): Promise<boolean> { + return this.doRpc("isValidDenom", denom, masterPub); + } + + signDeposit(offer: Offer, + cds: CoinWithDenom[]): Promise<PayCoinInfo> { + return this.doRpc("signDeposit", offer, cds); + } + + createEddsaKeypair(): Promise<{priv: string, pub: string}> { + return this.doRpc("createEddsaKeypair"); + } + + rsaUnblind(sig: string, bk: string, pk: string): Promise<string> { + return this.doRpc("rsaUnblind", sig, bk, pk); + } +}
\ No newline at end of file diff --git a/lib/wallet/cryptoLib.ts b/lib/wallet/cryptoLib.ts new file mode 100644 index 000000000..869ddbaff --- /dev/null +++ b/lib/wallet/cryptoLib.ts @@ -0,0 +1,222 @@ +/* + 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, If not, see <http://www.gnu.org/licenses/> + */ + +import {Denomination} from "./types"; +/** + * 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"; + + +export function main(worker: Worker) { + worker.onmessage = (msg: MessageEvent) => { + console.log("got data", msg.data); + 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[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}); + } +} + +console.log("hello, this is the crypto lib"); + +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.RsaBlindingKey.create(1024); + 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(), + mintBaseUrl: reserve.mint_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(), + }); + + 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 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, bk, pk): string { + let denomSig = native.rsaUnblind(native.RsaSignature.fromCrock(sig), + native.RsaBlindingKey.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 = []; + 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; + + 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..958c2de74 --- /dev/null +++ b/lib/wallet/cryptoWorker.ts @@ -0,0 +1,65 @@ +/* + 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, 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); + console.log("loaded"); + }) + .catch((e) => { + console.log("crypto worker failed"); + console.error(e.stack); + }); + +console.log("in worker thread"); + diff --git a/lib/wallet/db.ts b/lib/wallet/db.ts new file mode 100644 index 000000000..c7621c5ff --- /dev/null +++ b/lib/wallet/db.ts @@ -0,0 +1,109 @@ +/* + 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, If not, see <http://www.gnu.org/licenses/> + */ + +"use strict"; + +/** + * 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 = 1; + +/** + * 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) => { + let db = req.result; + console.log("DB: upgrade needed: oldVersion = " + e.oldVersion); + switch (e.oldVersion) { + case 0: // DB does not exist yet + const mints = db.createObjectStore("mints", {keyPath: "baseUrl"}); + mints.createIndex("pubKey", "masterPublicKey"); + db.createObjectStore("reserves", {keyPath: "reserve_pub"}); + db.createObjectStore("denoms", {keyPath: "denomPub"}); + const coins = db.createObjectStore("coins", {keyPath: "coinPub"}); + coins.createIndex("mintBaseUrl", "mintBaseUrl"); + 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; + } + }; + }); +} + + +export function exportDb(db): Promise<any> { + let dump = { + name: db.name, + version: db.version, + stores: {} + }; + + return new Promise((resolve, reject) => { + + let tx = db.transaction(db.objectStoreNames); + tx.addEventListener("complete", (e) => { + resolve(dump); + }); + for (let i = 0; i < db.objectStoreNames.length; i++) { + let name = db.objectStoreNames[i]; + let storeDump = {}; + dump.stores[name] = storeDump; + let store = tx.objectStore(name) + .openCursor() + .addEventListener("success", (e) => { + let cursor = e.target.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..b03bc9bc7 --- /dev/null +++ b/lib/wallet/emscriptif.ts @@ -0,0 +1,1006 @@ +/* + 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, 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) => Module.cwrap.apply(null, + args); + +var emsc = { + free: (ptr) => 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']), +}; + +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 { + private _nativePtr: number; + arena: Arena; + + abstract destroy(): void; + + constructor(arena?: Arena) { + this.nativePtr = null; + 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 !== undefined) { + 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) { + 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) { + 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) { + 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() { + if (this.nativePtr != 0) { + emsc.free(this.nativePtr); + } + } + + + static getZero(currency: string, a?: Arena): Amount { + let am = new Amount(null, 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); + } +} + + +abstract class PackedArenaObject extends ArenaObject { + abstract size(): number; + + constructor(a?: Arena) { + super(a); + } + + 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() { + if (this.nativePtr === null) { + 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 = []; + 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(null, 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: (string) => EddsaPrivateKey; +} +mixinStatic(EddsaPrivateKey, fromCrock); + + +function fromCrock(s: string) { + let x = new this(); + x.alloc(); + x.loadCrock(s); + return x; +} + + +function mixin(obj, method, name?: string) { + if (!name) { + name = method.name; + } + if (!name) { + throw Error("Mixin needs a name."); + } + obj.prototype[method.name] = method; +} + + +function mixinStatic(obj, method, 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 RsaBlindingKey extends ArenaObject { + static create(len: number, a?: Arena) { + let o = new RsaBlindingKey(a); + o.nativePtr = emscAlloc.rsa_blinding_key_create(len); + return o; + } + + static fromCrock: (s: string, a?: Arena) => RsaBlindingKey; + toCrock = makeToCrock(emscAlloc.rsa_blinding_key_encode); + + destroy() { + // TODO + } +} +mixinStatic(RsaBlindingKey, makeFromCrock(emscAlloc.rsa_blinding_key_decode)); + + +export class HashCode extends PackedArenaObject { + size() { + return 64; + } + + static fromCrock: (s: string) => HashCode; + + random(qualStr: string) { + let qual: RandomQuality; + switch (qualStr) { + case "weak": + qual = RandomQuality.WEAK; + break; + case "strong": + case null: + case undefined: + qual = RandomQuality.STRONG; + break; + case "nonce": + qual = RandomQuality.NONCE; + break; + default: + throw Error(`unknown crypto quality: ${qual}`); + } + 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 || init === null) { + this.nativePtr = emscAlloc.malloc(desiredSize); + } else { + this.nativePtr = init; + } + this.allocatedSize = desiredSize; + } + + static fromString(s: string, a?: Arena): ByteArray { + let hstr = emscAlloc.malloc(s.length + 1); + Module.writeStringToMemory(s, hstr); + return new ByteArray(s.length, hstr, a); + } + + static fromCrock(s: string, a?: Arena): ByteArray { + let hstr = emscAlloc.malloc(s.length + 1); + Module.writeStringToMemory(s, hstr); + let decodedLen = Math.floor((s.length * 5) / 8); + let ba = new ByteArray(decodedLen, null, a); + let res = emsc.string_to_data(hstr, s.length, 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.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; + 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], + ["denom_hash", HashCode] + ]; + } +} + + +interface Encodeable { + encode(arena?: Arena): ByteArray; +} + +function makeEncode(encodeFn) { + function encode(arena?: Arena) { + let ptr = emscAlloc.malloc(PTR_SIZE); + let len = encodeFn(this.getNative(), ptr); + let res = new ByteArray(len, null, 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: RsaBlindingKey, + 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: RsaBlindingKey, + 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..99913e558 --- /dev/null +++ b/lib/wallet/helpers.ts @@ -0,0 +1,65 @@ +/* + 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, If not, see <http://www.gnu.org/licenses/> + */ + + +/** + * Smaller helper functions that do not depend + * on the emscripten machinery. + */ + +import {AmountJson} from "./types"; + +export function substituteFulfillmentUrl(url: string, vars) { + 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 mint. + * + * See http://api.taler.net/wallet.html#general + */ +export function canonicalizeBaseUrl(url) { + 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 { + const res = /([0-9]+)(.[0-9]+)?\s*(\w+)/.exec(pretty); + if (!res) { + return null; + } + return { + value: parseInt(res[1], 10), + fraction: res[2] ? (parseFloat(`0.${res[2]}`) * 1e-6) : 0, + currency: res[3] + } +}
\ No newline at end of file diff --git a/lib/wallet/http.ts b/lib/wallet/http.ts new file mode 100644 index 000000000..3f7244e40 --- /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, 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) { + return this.req("post", url, {req: JSON.stringify(body)}); + } + + + postForm(url: string|uri.URI, form) { + return this.req("post", url, {req: form}); + } +} + + +export class RequestException { + constructor(detail) { + + } +}
\ No newline at end of file diff --git a/lib/wallet/query.ts b/lib/wallet/query.ts new file mode 100644 index 000000000..62411dab3 --- /dev/null +++ b/lib/wallet/query.ts @@ -0,0 +1,360 @@ +/* + 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, If not, see <http://www.gnu.org/licenses/> + */ + + +/** + * Database query abstractions. + * @module Query + * @author Florian Dold + */ + +"use strict"; + + +export function Query(db) { + 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: (any) => boolean): QueryStream<T>; + reduce<S>(f: (v: T, acc: S) => S, start?: S): Promise<S>; +} + + +/** + * Get an unresolved promise together with its extracted resolve / reject + * function. + * + * @returns {{resolve: any, reject: any, promise: Promise<T>}} + */ +function openPromise<T>() { + let resolve, reject; + const promise = new Promise<T>((res, rej) => { + resolve = res; + reject = rej; + }); + return {resolve, reject, promise}; +} + + +abstract class QueryStreamBase<T> implements QueryStream<T> { + abstract subscribe(f: (isDone: boolean, + value: any, + tx: IDBTransaction) => void); + + root: QueryRoot; + + constructor(root: QueryRoot) { + this.root = root; + } + + indexJoin<S>(storeName: string, + indexName: string, + key: any): QueryStream<[T,S]> { + this.root.addWork(null, storeName, false); + return new QueryStreamIndexJoin(this, storeName, indexName, key); + } + + filter(f: (any) => boolean): QueryStream<T> { + return new QueryStreamFilter(this, f); + } + + reduce(f, acc?): Promise<any> { + let leakedResolve; + let p = new Promise((resolve, reject) => { + leakedResolve = resolve; + }); + + this.subscribe((isDone, value) => { + if (isDone) { + leakedResolve(acc); + return; + } + acc = f(value, acc); + }); + + return Promise.resolve() + .then(() => this.root.finish()) + .then(() => p); + } +} + + +class QueryStreamFilter<T> extends QueryStreamBase<T> { + s: QueryStreamBase<T>; + filterFn; + + constructor(s: QueryStreamBase<T>, filterFn) { + super(s.root); + this.s = s; + this.filterFn = filterFn; + } + + subscribe(f) { + this.s.subscribe((isDone, value, tx) => { + if (isDone) { + f(true, undefined, tx); + return; + } + if (this.filterFn(value)) { + f(false, value, tx) + } + }); + } +} + + +class QueryStreamIndexJoin<T> extends QueryStreamBase<T> { + s: QueryStreamBase<T>; + storeName; + key; + indexName; + + constructor(s, storeName: string, indexName: string, key: any) { + super(s.root); + this.s = s; + this.storeName = storeName; + this.key = key; + this.indexName = indexName; + } + + subscribe(f) { + this.s.subscribe((isDone, value, tx) => { + if (isDone) { + f(true, undefined, tx); + return; + } + 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; + private options; + + constructor(qr, storeName, options) { + super(qr); + this.options = options; + this.storeName = storeName; + } + + subscribe(f) { + let doIt = (tx) => { + const {indexName = void 0, only = void 0} = this.options; + let s; + if (indexName !== void 0) { + s = tx.objectStore(this.storeName) + .index(this.options.indexName); + } else { + s = tx.objectStore(this.storeName); + } + let kr = undefined; + if (only !== void 0) { + kr = IDBKeyRange.only(this.options.only); + } + let req = s.openCursor(kr); + req.onsuccess = (e) => { + let cursor: IDBCursorWithValue = req.result; + if (cursor) { + f(false, cursor.value, tx); + cursor.continue(); + } else { + f(true, undefined, tx); + } + } + }; + + this.root.addWork(doIt, null, false); + } +} + + +class QueryRoot { + private work = []; + private db: IDBDatabase; + private stores = new Set(); + private kickoffPromise; + + /** + * Some operations is a write operation, + * and we need to do a "readwrite" transaction/ + */ + private hasWrite; + + constructor(db) { + this.db = db; + } + + iter<T>(storeName, {only = void 0, indexName = void 0} = {}): 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, val): 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, iterable): 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, val): 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, key): Promise<any> { + if (key === void 0) { + throw Error("key must not be undefined"); + } + + const {resolve, promise} = openPromise(); + + const doGet = (tx) => { + const req = tx.objectStore(storeName).get(key); + req.onsuccess = (r) => { + 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, indexName, key): Promise<any> { + if (key === void 0) { + throw Error("key must not be undefined"); + } + + const {resolve, promise} = openPromise(); + + const doGetIndexed = (tx) => { + const req = tx.objectStore(storeName).index(indexName).get(key); + req.onsuccess = (r) => { + 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((resolve, reject) => { + 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): QueryRoot { + const doDelete = (tx) => { + 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: (IDBTransaction) => void, + storeName: string, + isWrite: boolean) { + if (storeName) { + this.stores.add(storeName); + } + if (isWrite) { + this.hasWrite = true; + } + if (workFn) { + this.work.push(workFn); + } + } +}
\ No newline at end of file diff --git a/lib/wallet/types.ts b/lib/wallet/types.ts new file mode 100644 index 000000000..9c7b21b7c --- /dev/null +++ b/lib/wallet/types.ts @@ -0,0 +1,269 @@ +/* + 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, 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). + */ + +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 { + /** + * Mint URL where the bank should create the reserve. + * The URL is canonicalized in the response. + */ + @Checkable.String + mint: 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.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; + + @Checkable.Optional(Checkable.String) + pub_hash: string; + + static checked: (obj: any) => Denomination; +} + + +export interface IMintInfo { + baseUrl: string; + masterPublicKey: string; + denoms: Denomination[]; +} + +export interface ReserveCreationInfo { + mintInfo: IMintInfo; + selectedDenoms: Denomination[]; + withdrawFee: AmountJson; + overhead: AmountJson; +} + + +export interface PreCoin { + coinPub: string; + coinPriv: string; + reservePub: string; + denomPub: string; + blindingKey: string; + withdrawSig: string; + coinEv: string; + mintBaseUrl: string; + coinValue: AmountJson; +} + + +export interface Reserve { + mint_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; +} + + +export interface Coin { + coinPub: string; + coinPriv: string; + denomPub: string; + denomSig: string; + currentAmount: AmountJson; + mintBaseUrl: string; +} + + +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(); +}
\ No newline at end of file diff --git a/lib/wallet/wallet.ts b/lib/wallet/wallet.ts new file mode 100644 index 000000000..92fb92a4a --- /dev/null +++ b/lib/wallet/wallet.ts @@ -0,0 +1,957 @@ +/* + 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, 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, IMintInfo, Denomination, Notifier} 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"; + +"use strict"; + + +export interface CoinWithDenom { + coin: Coin; + denom: Denomination; +} + + +@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; +} + + +class MintInfo implements IMintInfo { + baseUrl: string; + masterPublicKey: string; + denoms: Denomination[]; + + constructor(obj: {baseUrl: string} & any) { + this.baseUrl = obj.baseUrl; + + if (obj.denoms) { + this.denoms = Array.from(<Denomination[]>obj.denoms); + } else { + this.denoms = []; + } + + if (typeof obj.masterPublicKey === "string") { + this.masterPublicKey = obj.masterPublicKey; + } + } + + static fresh(baseUrl: string): MintInfo { + return new MintInfo({baseUrl}); + } + + /** + * Merge new key information into the mint info. + * If the new key information is invalid (missing fields, + * invalid signatures), an exception is thrown, but the + * mint info is updated with the new information up until + * the first error. + */ + mergeKeys(newKeys: KeysJson, cryptoApi: CryptoApi): Promise<void> { + if (!this.masterPublicKey) { + this.masterPublicKey = newKeys.master_public_key; + } + + if (this.masterPublicKey != newKeys.master_public_key) { + throw Error("public keys do not match"); + } + + let ps = newKeys.denoms.map((newDenom) => { + let found = false; + for (let oldDenom of this.denoms) { + if (oldDenom.denom_pub === newDenom.denom_pub) { + let a = Object.assign({}, oldDenom); + let b = 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.log("old/new:"); + console.dir(a); + console.dir(b); + throw Error("denomination modified"); + } + found = true; + break; + } + } + + if (found) { + return Promise.resolve(); + } + + return cryptoApi + .isValidDenom(newDenom, this.masterPublicKey) + .then((valid) => { + if (!valid) { + throw Error("signature on denomination invalid"); + } + return cryptoApi.hashRsaPub(newDenom.denom_pub); + }) + .then((h) => { + this.denoms.push(Object.assign({}, newDenom, {pub_hash: h})); + }); + }); + + return Promise.all(ps).then(() => void 0); + } +} + + +@Checkable.Class +export class CreateReserveRequest { + /** + * The initial amount for the reserve. + */ + @Checkable.Value(AmountJson) + amount: AmountJson; + + /** + * Mint URL where the bank should create the reserve. + */ + @Checkable.String + mint: 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 MintHandle { + @Checkable.String + master_pub: string; + + @Checkable.String + url: string; + + static checked: (obj: any) => MintHandle; +} + + +@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(MintHandle)) + mints: MintHandle[]; + + @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; + + static checked: (obj: any) => Contract; +} + + +@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; +} + + +interface ConfirmPayRequest { + offer: Offer; +} + +interface MintCoins { + [mintUrl: string]: CoinWithDenom[]; +} + + +interface CoinPaySig { + coin_sig: string; + coin_pub: string; + ub_sig: string; + denom_pub: string; + f: AmountJson; +} + + +interface Transaction { + contractHash: string; + contract: Contract; + payReq: any; + merchantSig: string; +} + + +export interface Badge { + setText(s: string): void; + setColor(c: string): void; +} + + +function deepEquals(x, y) { + 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 getTalerStampSec(stamp: string) { + const m = stamp.match(/\/?Date\(([0-9]*)\)\/?/); + if (!m) { + return null; + } + return parseInt(m[1]); +} + + +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): Promise<HttpResponse>; + + postForm(url: string|uri.URI, form): Promise<HttpResponse>; +} + + +function copy(o) { + return JSON.parse(JSON.stringify(o)); +} + + +/** + * 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); + let 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; + + + 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(); + } + + + /** + * Get mints and associated coins that are still spendable, + * but only if the sum the coins' remaining value exceeds the payment amount. + */ + private getPossibleMintCoins(paymentAmount: AmountJson, + depositFeeLimit: AmountJson, + allowedMints: MintHandle[]): Promise<MintCoins> { + // Mapping from mint base URL to list of coins together with their + // denomination + let m: MintCoins = {}; + + function storeMintCoin(mc) { + let mint: IMintInfo = mc[0]; + let coin: Coin = mc[1]; + let cd = { + coin: coin, + denom: mint.denoms.find((e) => e.denom_pub === coin.denomPub) + }; + if (!cd.denom) { + throw Error("denom not found (database inconsistent)"); + } + if (cd.denom.value.currency !== paymentAmount.currency) { + console.warn("same pubkey for different currencies"); + return; + } + let x = m[mint.baseUrl]; + if (!x) { + m[mint.baseUrl] = [cd]; + } else { + x.push(cd); + } + } + + let ps = allowedMints.map((info) => { + console.log("Checking for merchant's mint", JSON.stringify(info)); + return Query(this.db) + .iter("mints", {indexName: "pubKey", only: info.master_pub}) + .indexJoin("coins", "mintBaseUrl", (mint) => mint.baseUrl) + .reduce(storeMintCoin); + }); + + return Promise.all(ps).then(() => { + let ret: MintCoins = {}; + + if (Object.keys(m).length == 0) { + console.log("not suitable mints found"); + } + + console.dir(m); + + // We try to find the first mint where we have + // enough coins to cover the paymentAmount with fees + // under depositFeeLimit + + nextMint: + 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 nextMint; + } + usableCoins.push(coins[i]); + if (Amounts.cmp(accAmount, minAmount) >= 0) { + ret[key] = usableCoins; + continue nextMint; + } + } + } + return ret; + }); + } + + + /** + * Record all information that is necessary to + * pay for a contract in the wallet's database. + */ + private recordConfirmPay(offer: Offer, + payCoinInfo: PayCoinInfo, + chosenMint: string): Promise<void> { + let payReq = {}; + 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["mint"] = URI(chosenMint).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, + }; + + console.log("pay request"); + console.dir(payReq); + + let historyEntry = { + type: "pay", + timestamp: (new Date).getTime(), + detail: { + merchantName: offer.contract.merchant.name, + amount: offer.contract.amount, + contractHash: offer.H_contract, + fulfillmentUrl: offer.contract.fulfillment_url + } + }; + + return Query(this.db) + .put("transactions", t) + .put("history", historyEntry) + .putAll("coins", payCoinInfo.map((pci) => pci.updatedCoin)) + .finish() + .then(() => { + this.notifier.notify(); + }); + } + + + /** + * Add a contract to the wallet and sign coins, + * but do not send them yet. + */ + confirmPay(offer: Offer): Promise<any> { + console.log("executing confirmPay"); + return Promise.resolve().then(() => { + return this.getPossibleMintCoins(offer.contract.amount, + offer.contract.max_fee, + offer.contract.mints) + }).then((mcs) => { + if (Object.keys(mcs).length == 0) { + console.log("not confirming payment, insufficient coins"); + return { + error: "coins-insufficient", + }; + } + let mintUrl = Object.keys(mcs)[0]; + + return this.cryptoApi.signDeposit(offer, mcs[mintUrl]) + .then((ds) => this.recordConfirmPay(offer, ds, mintUrl)) + .then(() => ({})); + }); + } + + + /** + * Retrieve all necessary information for looking up the contract + * with the given hash. + */ + executePayment(H_contract): Promise<any> { + return Promise.resolve().then(() => { + return Query(this.db) + .get("transactions", H_contract) + .then((t) => { + 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 initReserve(reserveRecord) { + this.updateMintFromUrl(reserveRecord.mint_base_url) + .then((mint) => + this.updateReserve(reserveRecord.reserve_pub, mint) + .then((reserve) => this.depleteReserve(reserve, + mint))) + .then(() => { + let depleted = { + type: "depleted-reserve", + timestamp: (new Date).getTime(), + detail: { + reservePub: reserveRecord.reserve_pub, + } + }; + return Query(this.db).put("history", depleted).finish(); + }) + .catch((e) => { + console.error("Failed to deplete reserve"); + console.error(e); + }); + } + + + /** + * Create a reserve, but do not flag it as confirmed yet. + */ + createReserve(req: CreateReserveRequest): Promise<CreateReserveResponse> { + return this.cryptoApi.createEddsaKeypair().then((keypair) => { + const now = (new Date).getTime(); + const canonMint = canonicalizeBaseUrl(req.mint); + + const reserveRecord = { + reserve_pub: keypair.pub, + reserve_priv: keypair.priv, + mint_base_url: canonMint, + created: now, + last_query: null, + current_amount: null, + requested_amount: req.amount, + confirmed: false, + }; + + + const historyEntry = { + type: "create-reserve", + timestamp: now, + detail: { + requestedAmount: req.amount, + reservePub: reserveRecord.reserve_pub, + } + }; + + return Query(this.db) + .put("reserves", reserveRecord) + .put("history", historyEntry) + .finish() + .then(() => { + let r: CreateReserveResponse = { + mint: canonMint, + 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 mint 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. + */ + confirmReserve(req: ConfirmReserveRequest): Promise<void> { + const now = (new Date).getTime(); + const historyEntry = { + type: "confirm-reserve", + timestamp: now, + detail: { + reservePub: req.reservePub, + } + }; + return Query(this.db) + .get("reserves", req.reservePub) + .then((r) => { + r.confirmed = true; + return Query(this.db) + .put("reserves", r) + .put("history", historyEntry) + .finish() + .then(() => { + // Do this in the background + this.initReserve(r); + }); + }); + } + + + private withdrawExecute(pc: PreCoin): Promise<Coin> { + return Query(this.db) + .get("reserves", pc.reservePub) + .then((r) => { + 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(r.mint_base_url); + return this.http.postJson(reqUrl, wd); + }) + .then(resp => { + if (resp.status != 200) { + throw new RequestException({ + hint: "Withdrawal failed", + status: resp.status + }); + } + let r = JSON.parse(resp.responseText); + return this.cryptoApi.rsaUnblind(r.ev_sig, pc.blindingKey, pc.denomPub) + .then((denomSig) => { + let coin: Coin = { + coinPub: pc.coinPub, + coinPriv: pc.coinPriv, + denomPub: pc.denomPub, + denomSig: denomSig, + currentAmount: pc.coinValue, + mintBaseUrl: pc.mintBaseUrl, + }; + return coin; + + }); + }); + } + + storeCoin(coin: Coin): Promise<void> { + let historyEntry = { + type: "withdraw", + timestamp: (new Date).getTime(), + detail: { + coinPub: coin.coinPub, + } + }; + return Query(this.db) + .delete("precoins", coin.coinPub) + .add("coins", coin) + .add("history", historyEntry) + .finish() + .then(() => { + this.notifier.notify(); + }); + } + + + /** + * Withdraw one coins of the given denomination from the given reserve. + */ + private withdraw(denom: Denomination, reserve: Reserve): Promise<void> { + console.log("creating pre coin at", new Date()); + return this.cryptoApi + .createPreCoin(denom, reserve) + .then((preCoin) => { + return Query(this.db) + .put("precoins", preCoin) + .finish() + .then(() => this.withdrawExecute(preCoin)) + .then((c) => this.storeCoin(c)); + }); + + } + + + /** + * Withdraw coins from a reserve until it is empty. + */ + private depleteReserve(reserve, mint: MintInfo): Promise<void> { + let denomsAvailable: Denomination[] = copy(mint.denoms); + let denomsForWithdraw = getWithdrawDenomList(reserve.current_amount, + denomsAvailable); + + let ps = denomsForWithdraw.map((denom) => { + console.log("withdrawing", JSON.stringify(denom)); + // Do the withdraw asynchronously, so crypto is interleaved + // with requests + return this.withdraw(denom, reserve); + }); + + return Promise.all(ps).then(() => void 0); + } + + + /** + * Update the information about a reserve that is stored in the wallet + * by quering the reserve's mint. + */ + private updateReserve(reservePub: string, mint: MintInfo): Promise<Reserve> { + return Query(this.db) + .get("reserves", reservePub) + .then((reserve) => { + let reqUrl = URI("reserve/status").absoluteTo(mint.baseUrl); + reqUrl.query({'reserve_pub': reservePub}); + return this.http.get(reqUrl).then(resp => { + 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(), + detail: { + reservePub, + oldAmount, + newAmount + } + }; + return Query(this.db) + .put("reserves", reserve) + .finish() + .then(() => reserve); + }); + }); + } + + + getReserveCreationInfo(baseUrl: string, + amount: AmountJson): Promise<ReserveCreationInfo> { + return this.updateMintFromUrl(baseUrl) + .then((mintInfo: IMintInfo) => { + let selectedDenoms = getWithdrawDenomList(amount, + mintInfo.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 ret: ReserveCreationInfo = { + mintInfo, + selectedDenoms, + withdrawFee: acc, + overhead: Amounts.sub(amount, actualCoinCost).amount, + }; + return ret; + }); + } + + + /** + * Update or add mint DB entry by fetching the /keys information. + * Optionally link the reserve entry to the new or existing + * mint entry in then DB. + */ + updateMintFromUrl(baseUrl): Promise<MintInfo> { + baseUrl = canonicalizeBaseUrl(baseUrl); + let reqUrl = URI("keys").absoluteTo(baseUrl); + return this.http.get(reqUrl).then((resp) => { + if (resp.status != 200) { + throw Error("/keys request failed"); + } + let mintKeysJson = KeysJson.checked(JSON.parse(resp.responseText)); + + return Query(this.db).get("mints", baseUrl).then((r) => { + let mintInfo; + console.dir(r); + + if (!r) { + mintInfo = MintInfo.fresh(baseUrl); + console.log("making fresh mint"); + } else { + mintInfo = new MintInfo(r); + console.log("using old mint"); + } + + return mintInfo.mergeKeys(mintKeysJson, this.cryptoApi) + .then(() => { + return Query(this.db) + .put("mints", mintInfo) + .finish() + .then(() => mintInfo); + }); + + }); + }); + } + + + /** + * Retrieve a mapping from currency name to the amount + * that is currenctly available for spending in the wallet. + */ + getBalances(): Promise<any> { + function collectBalances(c: Coin, 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; + } + + return Query(this.db) + .iter("coins") + .reduce(collectBalances, {}); + } + + + /** + * Retrive the full event history for this wallet. + */ + getHistory(): Promise<any[]> { + function collect(x, acc) { + acc.push(x); + return acc; + } + + return Query(this.db) + .iter("history", {indexName: "timestamp"}) + .reduce(collect, []) + } + + checkRepurchase(contract: Contract): Promise<CheckRepurchaseResult> { + if (!contract.repurchase_correlation_id) { + console.log("no repurchase: no correlation id"); + return Promise.resolve({isRepurchase: false}); + } + return Query(this.db) + .getIndexed("transactions", + "repurchase", + [contract.merchant_pub, contract.repurchase_correlation_id]) + .then((result: Transaction) => { + console.log("db result", result); + let isRepurchase; + 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..9871b6e7f --- /dev/null +++ b/lib/wallet/wxApi.ts @@ -0,0 +1,40 @@ +/* + 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, If not, see <http://www.gnu.org/licenses/> + */ + +import {AmountJson} from "./types"; +import {ReserveCreationInfo} from "./types"; + +/** + * Interface to the wallet through WebExtension messaging. + */ + + +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); + }); + }); +}
\ No newline at end of file diff --git a/lib/wallet/wxMessaging.ts b/lib/wallet/wxMessaging.ts new file mode 100644 index 000000000..740873d88 --- /dev/null +++ b/lib/wallet/wxMessaging.ts @@ -0,0 +1,230 @@ +/* + 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, 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 "./wallet"; + +"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) => Promise<any>; + +function makeHandlers(db: IDBDatabase, + wallet: Wallet): {[msg: string]: Handler} { + return { + ["balances"]: function(detail) { + return wallet.getBalances(); + }, + ["dump-db"]: function(detail) { + return exportDb(db); + }, + ["reset"]: function(detail) { + let tx = db.transaction(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) { + const d = { + mint: detail.mint, + amount: detail.amount, + }; + const req = CreateReserveRequest.checked(d); + return wallet.createReserve(req); + }, + ["confirm-reserve"]: function(detail) { + // TODO: make it a checkable + const d = { + reservePub: detail.reservePub + }; + const req = ConfirmReserveRequest.checked(d); + return wallet.confirmReserve(req); + }, + ["confirm-pay"]: function(detail) { + let 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); + }, + ["execute-payment"]: function(detail) { + return wallet.executePayment(detail.H_contract); + }, + ["mint-info"]: function(detail) { + if (!detail.baseUrl) { + return Promise.resolve({error: "bad url"}); + } + return wallet.updateMintFromUrl(detail.baseUrl); + }, + ["reserve-creation-info"]: function(detail) { + 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) { + let contract = Contract.checked(detail.contract); + return wallet.checkRepurchase(contract); + }, + ["get-history"]: function(detail) { + // TODO: limit history length + return wallet.getHistory(); + }, + }; +} + + +class ChromeBadge implements Badge { + setText(s: string) { + chrome.browserAction.setBadgeText({text: s}); + } + + setColor(c: string) { + chrome.browserAction.setBadgeBackgroundColor({color: c}); + } +} + + +function dispatch(handlers, req, sendResponse) { + if (req.type in handlers) { + Promise + .resolve() + .then(() => { + const p = handlers[req.type](req.detail); + + return p.then((r) => { + sendResponse(r); + }) + }) + .catch((e) => { + console.log(`exception during wallet handler for '${req.type}'`); + console.log("request", req); + console.error(e); + sendResponse({ + error: "exception", + hint: e.message, + stack: e.stack.toString() + }); + }); + // The sendResponse call is async + return true; + } else { + console.error(`Request type ${JSON.stringify(req)} unknown, req ${req.type}`); + sendResponse({error: "request unknown"}); + // 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}); + } + } +} + + +export function wxMain() { + chrome.browserAction.setBadgeText({text: ""}); + + Promise.resolve() + .then(() => { + return openTalerDb(); + }) + .catch((e) => { + console.error("could not open database"); + console.error(e); + }) + .then((db) => { + let http = new BrowserHttpLib(); + let badge = new ChromeBadge(); + let notifier = new ChromeNotifier(); + let wallet = new Wallet(db, http, badge, notifier); + let handlers = makeHandlers(db, wallet); + chrome.runtime.onMessage.addListener((req, sender, sendResponse) => { + try { + return dispatch(handlers, req, 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; + } + }); + }) + .catch((e) => { + console.error("could not initialize wallet messaging"); + console.error(e); + }); +}
\ No newline at end of file |