diff options
Diffstat (limited to 'packages/taler-wallet-core/src/operations/exchanges.ts')
-rw-r--r-- | packages/taler-wallet-core/src/operations/exchanges.ts | 732 |
1 files changed, 0 insertions, 732 deletions
diff --git a/packages/taler-wallet-core/src/operations/exchanges.ts b/packages/taler-wallet-core/src/operations/exchanges.ts deleted file mode 100644 index 629957efb..000000000 --- a/packages/taler-wallet-core/src/operations/exchanges.ts +++ /dev/null @@ -1,732 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019 GNUnet e.V. - - GNU 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. - - GNU 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 - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * Imports. - */ -import { - Amounts, - Auditor, - canonicalizeBaseUrl, - codecForExchangeKeysJson, - codecForExchangeWireJson, - compare, - Denomination, - Duration, - durationFromSpec, - ExchangeSignKeyJson, - ExchangeWireJson, - getTimestampNow, - isTimestampExpired, - Logger, - NotificationType, - parsePaytoUri, - Recoup, - TalerErrorCode, - URL, - TalerErrorDetails, - Timestamp, -} from "@gnu-taler/taler-util"; -import { decodeCrock, encodeCrock, hash } from "@gnu-taler/taler-util"; -import { CryptoApi } from "../crypto/workers/cryptoApi.js"; -import { - DenominationRecord, - DenominationVerificationStatus, - ExchangeDetailsRecord, - ExchangeRecord, - WalletStoresV1, - WireFee, - WireInfo, -} from "../db.js"; -import { - getExpiryTimestamp, - HttpRequestLibrary, - readSuccessResponseJsonOrThrow, - readSuccessResponseTextOrThrow, -} from "../util/http.js"; -import { DbAccess, GetReadOnlyAccess } from "../util/query.js"; -import { initRetryInfo, updateRetryInfoTimeout } from "../util/retries.js"; -import { - guardOperationException, - makeErrorDetails, - OperationFailedError, -} from "../errors.js"; -import { InternalWalletState, TrustInfo } from "../common.js"; -import { - WALLET_CACHE_BREAKER_CLIENT_VERSION, - WALLET_EXCHANGE_PROTOCOL_VERSION, -} from "../versions.js"; - -const logger = new Logger("exchanges.ts"); - -function denominationRecordFromKeys( - exchangeBaseUrl: string, - exchangeMasterPub: string, - listIssueDate: Timestamp, - denomIn: Denomination, -): DenominationRecord { - const denomPubHash = encodeCrock(hash(decodeCrock(denomIn.denom_pub))); - const d: DenominationRecord = { - denomPub: denomIn.denom_pub, - denomPubHash, - exchangeBaseUrl, - exchangeMasterPub, - feeDeposit: Amounts.parseOrThrow(denomIn.fee_deposit), - feeRefresh: Amounts.parseOrThrow(denomIn.fee_refresh), - feeRefund: Amounts.parseOrThrow(denomIn.fee_refund), - feeWithdraw: Amounts.parseOrThrow(denomIn.fee_withdraw), - isOffered: true, - isRevoked: false, - masterSig: denomIn.master_sig, - stampExpireDeposit: denomIn.stamp_expire_deposit, - stampExpireLegal: denomIn.stamp_expire_legal, - stampExpireWithdraw: denomIn.stamp_expire_withdraw, - stampStart: denomIn.stamp_start, - verificationStatus: DenominationVerificationStatus.Unverified, - value: Amounts.parseOrThrow(denomIn.value), - listIssueDate, - }; - return d; -} - -async function handleExchangeUpdateError( - ws: InternalWalletState, - baseUrl: string, - err: TalerErrorDetails, -): Promise<void> { - await ws.db - .mktx((x) => ({ exchanges: x.exchanges })) - .runReadOnly(async (tx) => { - const exchange = await tx.exchanges.get(baseUrl); - if (!exchange) { - return; - } - exchange.retryInfo.retryCounter++; - updateRetryInfoTimeout(exchange.retryInfo); - exchange.lastError = err; - }); - if (err) { - ws.notify({ type: NotificationType.ExchangeOperationError, error: err }); - } -} - -function getExchangeRequestTimeout(e: ExchangeRecord): Duration { - return { d_ms: 5000 }; -} - -export interface ExchangeTosDownloadResult { - tosText: string; - tosEtag: string; - tosContentType: string; -} - -export async function downloadExchangeWithTermsOfService( - exchangeBaseUrl: string, - http: HttpRequestLibrary, - timeout: Duration, - contentType: string, -): Promise<ExchangeTosDownloadResult> { - const reqUrl = new URL("terms", exchangeBaseUrl); - reqUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION); - const headers = { - Accept: contentType, - }; - - const resp = await http.get(reqUrl.href, { - headers, - timeout, - }); - const tosText = await readSuccessResponseTextOrThrow(resp); - const tosEtag = resp.headers.get("etag") || "unknown"; - const tosContentType = resp.headers.get("content-type") || "text/plain"; - - return { tosText, tosEtag, tosContentType }; -} - -/** - * Get exchange details from the database. - */ -export async function getExchangeDetails( - tx: GetReadOnlyAccess<{ - exchanges: typeof WalletStoresV1.exchanges; - exchangeDetails: typeof WalletStoresV1.exchangeDetails; - }>, - exchangeBaseUrl: string, -): Promise<ExchangeDetailsRecord | undefined> { - const r = await tx.exchanges.get(exchangeBaseUrl); - if (!r) { - return; - } - const dp = r.detailsPointer; - if (!dp) { - return; - } - const { currency, masterPublicKey } = dp; - return await tx.exchangeDetails.get([r.baseUrl, currency, masterPublicKey]); -} - -getExchangeDetails.makeContext = (db: DbAccess<typeof WalletStoresV1>) => - db.mktx((x) => ({ - exchanges: x.exchanges, - exchangeDetails: x.exchangeDetails, - })); - -export async function acceptExchangeTermsOfService( - ws: InternalWalletState, - exchangeBaseUrl: string, - etag: string | undefined, -): Promise<void> { - await ws.db - .mktx((x) => ({ - exchanges: x.exchanges, - exchangeDetails: x.exchangeDetails, - })) - .runReadWrite(async (tx) => { - const d = await getExchangeDetails(tx, exchangeBaseUrl); - if (d) { - d.termsOfServiceAcceptedEtag = etag; - await tx.exchangeDetails.put(d); - } - }); -} - -async function validateWireInfo( - wireInfo: ExchangeWireJson, - masterPublicKey: string, - cryptoApi: CryptoApi, -): Promise<WireInfo> { - for (const a of wireInfo.accounts) { - logger.trace("validating exchange acct"); - const isValid = await cryptoApi.isValidWireAccount( - a.payto_uri, - a.master_sig, - masterPublicKey, - ); - if (!isValid) { - throw Error("exchange acct signature invalid"); - } - } - const feesForType: { [wireMethod: string]: WireFee[] } = {}; - for (const wireMethod of Object.keys(wireInfo.fees)) { - const feeList: WireFee[] = []; - for (const x of wireInfo.fees[wireMethod]) { - const startStamp = x.start_date; - const endStamp = x.end_date; - const fee: WireFee = { - closingFee: Amounts.parseOrThrow(x.closing_fee), - endStamp, - sig: x.sig, - startStamp, - wireFee: Amounts.parseOrThrow(x.wire_fee), - }; - const isValid = await cryptoApi.isValidWireFee( - wireMethod, - fee, - masterPublicKey, - ); - if (!isValid) { - throw Error("exchange wire fee signature invalid"); - } - feeList.push(fee); - } - feesForType[wireMethod] = feeList; - } - - return { - accounts: wireInfo.accounts, - feesForType, - }; -} - -/** - * Fetch wire information for an exchange. - * - * @param exchangeBaseUrl Exchange base URL, assumed to be already normalized. - */ -async function downloadExchangeWithWireInfo( - exchangeBaseUrl: string, - http: HttpRequestLibrary, - timeout: Duration, -): Promise<ExchangeWireJson> { - const reqUrl = new URL("wire", exchangeBaseUrl); - reqUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION); - - const resp = await http.get(reqUrl.href, { - timeout, - }); - const wireInfo = await readSuccessResponseJsonOrThrow( - resp, - codecForExchangeWireJson(), - ); - - return wireInfo; -} - -export async function updateExchangeFromUrl( - ws: InternalWalletState, - baseUrl: string, - acceptedFormat?: string[], - forceNow = false, -): Promise<{ - exchange: ExchangeRecord; - exchangeDetails: ExchangeDetailsRecord; -}> { - const onOpErr = (e: TalerErrorDetails): Promise<void> => - handleExchangeUpdateError(ws, baseUrl, e); - return await guardOperationException( - () => updateExchangeFromUrlImpl(ws, baseUrl, acceptedFormat, forceNow), - onOpErr, - ); -} - -async function provideExchangeRecord( - ws: InternalWalletState, - baseUrl: string, - now: Timestamp, -): Promise<ExchangeRecord> { - return await ws.db - .mktx((x) => ({ exchanges: x.exchanges })) - .runReadWrite(async (tx) => { - let r = await tx.exchanges.get(baseUrl); - if (!r) { - r = { - permanent: true, - baseUrl: baseUrl, - retryInfo: initRetryInfo(), - detailsPointer: undefined, - lastUpdate: undefined, - nextUpdate: now, - nextRefreshCheck: now, - }; - await tx.exchanges.put(r); - } - return r; - }); -} - -interface ExchangeKeysDownloadResult { - masterPublicKey: string; - currency: string; - auditors: Auditor[]; - currentDenominations: DenominationRecord[]; - protocolVersion: string; - signingKeys: ExchangeSignKeyJson[]; - reserveClosingDelay: Duration; - expiry: Timestamp; - recoup: Recoup[]; - listIssueDate: Timestamp; -} - -/** - * Download and validate an exchange's /keys data. - */ -async function downloadKeysInfo( - baseUrl: string, - http: HttpRequestLibrary, - timeout: Duration, -): Promise<ExchangeKeysDownloadResult> { - const keysUrl = new URL("keys", baseUrl); - keysUrl.searchParams.set("cacheBreaker", WALLET_CACHE_BREAKER_CLIENT_VERSION); - - const resp = await http.get(keysUrl.href, { - timeout, - }); - const exchangeKeysJson = await readSuccessResponseJsonOrThrow( - resp, - codecForExchangeKeysJson(), - ); - - logger.info("received /keys response"); - - if (exchangeKeysJson.denoms.length === 0) { - const opErr = makeErrorDetails( - TalerErrorCode.WALLET_EXCHANGE_DENOMINATIONS_INSUFFICIENT, - "exchange doesn't offer any denominations", - { - exchangeBaseUrl: baseUrl, - }, - ); - throw new OperationFailedError(opErr); - } - - const protocolVersion = exchangeKeysJson.version; - - const versionRes = compare(WALLET_EXCHANGE_PROTOCOL_VERSION, protocolVersion); - if (versionRes?.compatible != true) { - const opErr = makeErrorDetails( - TalerErrorCode.WALLET_EXCHANGE_PROTOCOL_VERSION_INCOMPATIBLE, - "exchange protocol version not compatible with wallet", - { - exchangeProtocolVersion: protocolVersion, - walletProtocolVersion: WALLET_EXCHANGE_PROTOCOL_VERSION, - }, - ); - throw new OperationFailedError(opErr); - } - - const currency = Amounts.parseOrThrow( - exchangeKeysJson.denoms[0].value, - ).currency.toUpperCase(); - - return { - masterPublicKey: exchangeKeysJson.master_public_key, - currency, - auditors: exchangeKeysJson.auditors, - currentDenominations: exchangeKeysJson.denoms.map((d) => - denominationRecordFromKeys( - baseUrl, - exchangeKeysJson.master_public_key, - exchangeKeysJson.list_issue_date, - d, - ), - ), - protocolVersion: exchangeKeysJson.version, - signingKeys: exchangeKeysJson.signkeys, - reserveClosingDelay: exchangeKeysJson.reserve_closing_delay, - expiry: getExpiryTimestamp(resp, { - minDuration: durationFromSpec({ hours: 1 }), - }), - recoup: exchangeKeysJson.recoup ?? [], - listIssueDate: exchangeKeysJson.list_issue_date, - }; -} - -/** - * Update or add exchange DB entry by fetching the /keys and /wire information. - * Optionally link the reserve entry to the new or existing - * exchange entry in then DB. - */ -async function updateExchangeFromUrlImpl( - ws: InternalWalletState, - baseUrl: string, - acceptedFormat?: string[], - forceNow = false, -): Promise<{ - exchange: ExchangeRecord; - exchangeDetails: ExchangeDetailsRecord; -}> { - logger.trace(`updating exchange info for ${baseUrl}`); - const now = getTimestampNow(); - baseUrl = canonicalizeBaseUrl(baseUrl); - - const r = await provideExchangeRecord(ws, baseUrl, now); - - if (!forceNow && r && !isTimestampExpired(r.nextUpdate)) { - const res = await ws.db - .mktx((x) => ({ - exchanges: x.exchanges, - exchangeDetails: x.exchangeDetails, - })) - .runReadOnly(async (tx) => { - const exchange = await tx.exchanges.get(baseUrl); - if (!exchange) { - return; - } - const exchangeDetails = await getExchangeDetails(tx, baseUrl); - if (!exchangeDetails) { - return; - } - return { exchange, exchangeDetails }; - }); - if (res) { - logger.info("using existing exchange info"); - return res; - } - } - - logger.info("updating exchange /keys info"); - - const timeout = getExchangeRequestTimeout(r); - - const keysInfo = await downloadKeysInfo(baseUrl, ws.http, timeout); - - logger.info("updating exchange /wire info"); - const wireInfoDownload = await downloadExchangeWithWireInfo( - baseUrl, - ws.http, - timeout, - ); - - logger.info("validating exchange /wire info"); - - const wireInfo = await validateWireInfo( - wireInfoDownload, - keysInfo.masterPublicKey, - ws.cryptoApi, - ); - - logger.info("finished validating exchange /wire info"); - - let tosFound: ExchangeTosDownloadResult | undefined; - //Remove this when exchange supports multiple content-type in accept header - if (acceptedFormat) for (const format of acceptedFormat) { - const resp = await downloadExchangeWithTermsOfService( - baseUrl, - ws.http, - timeout, - format - ); - if (resp.tosContentType === format) { - tosFound = resp - break - } - } - // If none of the specified format was found try text/plain - const tosDownload = tosFound !== undefined ? tosFound : - await downloadExchangeWithTermsOfService( - baseUrl, - ws.http, - timeout, - "text/plain" - ); - - let recoupGroupId: string | undefined = undefined; - - logger.trace("updating exchange info in database"); - - const updated = await ws.db - .mktx((x) => ({ - exchanges: x.exchanges, - exchangeDetails: x.exchangeDetails, - denominations: x.denominations, - coins: x.coins, - refreshGroups: x.refreshGroups, - recoupGroups: x.recoupGroups, - })) - .runReadWrite(async (tx) => { - const r = await tx.exchanges.get(baseUrl); - if (!r) { - logger.warn(`exchange ${baseUrl} no longer present`); - return; - } - let details = await getExchangeDetails(tx, r.baseUrl); - if (details) { - // FIXME: We need to do some consistency checks! - } - // FIXME: validate signing keys and merge with old set - details = { - auditors: keysInfo.auditors, - currency: keysInfo.currency, - masterPublicKey: keysInfo.masterPublicKey, - protocolVersion: keysInfo.protocolVersion, - signingKeys: keysInfo.signingKeys, - reserveClosingDelay: keysInfo.reserveClosingDelay, - exchangeBaseUrl: r.baseUrl, - wireInfo, - termsOfServiceText: tosDownload.tosText, - termsOfServiceAcceptedEtag: undefined, - termsOfServiceContentType: tosDownload.tosContentType, - termsOfServiceLastEtag: tosDownload.tosEtag, - termsOfServiceAcceptedTimestamp: getTimestampNow(), - }; - // FIXME: only update if pointer got updated - r.lastError = undefined; - r.retryInfo = initRetryInfo(); - r.lastUpdate = getTimestampNow(); - r.nextUpdate = keysInfo.expiry; - // New denominations might be available. - r.nextRefreshCheck = getTimestampNow(); - r.detailsPointer = { - currency: details.currency, - masterPublicKey: details.masterPublicKey, - // FIXME: only change if pointer really changed - updateClock: getTimestampNow(), - }; - await tx.exchanges.put(r); - await tx.exchangeDetails.put(details); - - logger.trace("updating denominations in database"); - const currentDenomSet = new Set<string>( - keysInfo.currentDenominations.map((x) => x.denomPubHash), - ); - for (const currentDenom of keysInfo.currentDenominations) { - const oldDenom = await tx.denominations.get([ - baseUrl, - currentDenom.denomPubHash, - ]); - if (oldDenom) { - // FIXME: Do consistency check, report to auditor if necessary. - } else { - await tx.denominations.put(currentDenom); - } - } - - // Update list issue date for all denominations, - // and mark non-offered denominations as such. - await tx.denominations.indexes.byExchangeBaseUrl - .iter(r.baseUrl) - .forEachAsync(async (x) => { - if (!currentDenomSet.has(x.denomPubHash)) { - // FIXME: Here, an auditor report should be created, unless - // the denomination is really legally expired. - if (x.isOffered) { - x.isOffered = false; - logger.info( - `setting denomination ${x.denomPubHash} to offered=false`, - ); - } - } else { - x.listIssueDate = keysInfo.listIssueDate; - if (!x.isOffered) { - x.isOffered = true; - logger.info( - `setting denomination ${x.denomPubHash} to offered=true`, - ); - } - } - await tx.denominations.put(x); - }); - - logger.trace("done updating denominations in database"); - - // Handle recoup - const recoupDenomList = keysInfo.recoup; - const newlyRevokedCoinPubs: string[] = []; - logger.trace("recoup list from exchange", recoupDenomList); - for (const recoupInfo of recoupDenomList) { - const oldDenom = await tx.denominations.get([ - r.baseUrl, - recoupInfo.h_denom_pub, - ]); - if (!oldDenom) { - // We never even knew about the revoked denomination, all good. - continue; - } - if (oldDenom.isRevoked) { - // We already marked the denomination as revoked, - // this implies we revoked all coins - logger.trace("denom already revoked"); - continue; - } - logger.trace("revoking denom", recoupInfo.h_denom_pub); - oldDenom.isRevoked = true; - await tx.denominations.put(oldDenom); - const affectedCoins = await tx.coins.indexes.byDenomPubHash - .iter(recoupInfo.h_denom_pub) - .toArray(); - for (const ac of affectedCoins) { - newlyRevokedCoinPubs.push(ac.coinPub); - } - } - if (newlyRevokedCoinPubs.length != 0) { - logger.trace("recouping coins", newlyRevokedCoinPubs); - recoupGroupId = await ws.recoupOps.createRecoupGroup( - ws, - tx, - newlyRevokedCoinPubs, - ); - } - return { - exchange: r, - exchangeDetails: details, - }; - }); - - if (recoupGroupId) { - // Asynchronously start recoup. This doesn't need to finish - // for the exchange update to be considered finished. - ws.recoupOps.processRecoupGroup(ws, recoupGroupId).catch((e) => { - logger.error("error while recouping coins:", e); - }); - } - - if (!updated) { - throw Error("something went wrong with updating the exchange"); - } - - logger.trace("done updating exchange info in database"); - - return { - exchange: updated.exchange, - exchangeDetails: updated.exchangeDetails, - }; -} - -export async function getExchangePaytoUri( - ws: InternalWalletState, - exchangeBaseUrl: string, - supportedTargetTypes: string[], -): Promise<string> { - // We do the update here, since the exchange might not even exist - // yet in our database. - const details = await getExchangeDetails - .makeContext(ws.db) - .runReadOnly(async (tx) => { - return getExchangeDetails(tx, exchangeBaseUrl); - }); - const accounts = details?.wireInfo.accounts ?? []; - for (const account of accounts) { - const res = parsePaytoUri(account.payto_uri); - if (!res) { - continue; - } - if (supportedTargetTypes.includes(res.targetType)) { - return account.payto_uri; - } - } - throw Error("no matching exchange account found"); -} - -/** - * Check if and how an exchange is trusted and/or audited. - */ -export async function getExchangeTrust( - ws: InternalWalletState, - exchangeInfo: ExchangeRecord, -): Promise<TrustInfo> { - let isTrusted = false; - let isAudited = false; - - return await ws.db - .mktx((x) => ({ - exchanges: x.exchanges, - exchangeDetails: x.exchangeDetails, - exchangesTrustStore: x.exchangeTrust, - auditorTrust: x.auditorTrust, - })) - .runReadOnly(async (tx) => { - const exchangeDetails = await getExchangeDetails( - tx, - exchangeInfo.baseUrl, - ); - - if (!exchangeDetails) { - throw Error(`exchange ${exchangeInfo.baseUrl} details not available`); - } - const exchangeTrustRecord = await tx.exchangesTrustStore.indexes.byExchangeMasterPub.get( - exchangeDetails.masterPublicKey, - ); - if ( - exchangeTrustRecord && - exchangeTrustRecord.uids.length > 0 && - exchangeTrustRecord.currency === exchangeDetails.currency - ) { - isTrusted = true; - } - - for (const auditor of exchangeDetails.auditors) { - const auditorTrustRecord = await tx.auditorTrust.indexes.byAuditorPub.get( - auditor.auditor_pub, - ); - if (auditorTrustRecord && auditorTrustRecord.uids.length > 0) { - isAudited = true; - break; - } - } - - return { isTrusted, isAudited }; - }); -} |