taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit bc383614f032c1f7cde0016ad0d858d04349b164
parent 34cff64515ddabe6e069d7dd2efdb130712baba2
Author: Florian Dold <dold@taler.net>
Date:   Thu, 20 Aug 2026 19:06:50 +0200

wallet-core: verify denomination auditor attestations

Diffstat:
Apackages/taler-wallet-core/src/auditorTrust.ts | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/balance.ts | 51++++++++++++++++++++++++++++++++++++++++-----------
Mpackages/taler-wallet-core/src/coinSelection.test.ts | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/coinSelection.ts | 42+++++++++++++++++++++++++++++++++++-------
Mpackages/taler-wallet-core/src/crypto/cryptoImplementation.test.ts | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/crypto/cryptoImplementation.ts | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/db-common.ts | 14+++++++++++++-
Mpackages/taler-wallet-core/src/dbtx-conformance-cases.ts | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/dbtx-indexeddb.ts | 38+++++++++++++++++++++++++++++++++++---
Mpackages/taler-wallet-core/src/dbtx-shared.ts | 40+++++++++++++++++++++++++++++++++++++---
Mpackages/taler-wallet-core/src/dbtx-sqlite.ts | 16++++++++++++++--
Mpackages/taler-wallet-core/src/dbtx.ts | 15+++++++++++++--
Mpackages/taler-wallet-core/src/exchanges.ts | 254++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
13 files changed, 745 insertions(+), 45 deletions(-)

diff --git a/packages/taler-wallet-core/src/auditorTrust.ts b/packages/taler-wallet-core/src/auditorTrust.ts @@ -0,0 +1,57 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + 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. + */ + +import type { WalletExchangeAuditor } from "./db-common.js"; + +export interface AuditorTrustRequirement { + auditorBaseUrl?: string; + auditorPub?: string; + denomPubHash?: string; +} + +/** + * Test whether a persisted auditor entry provides verified trust for the + * requested denomination. Entries written by older wallets deliberately do + * not satisfy this check: their signatures were never verified. + */ +export function auditorProvidesVerifiedTrust( + auditor: WalletExchangeAuditor, + requirement: AuditorTrustRequirement, +): boolean { + if (auditor.walletAuditorSignaturesVerified !== true) { + return false; + } + if ( + requirement.auditorBaseUrl != null && + auditor.auditor_url !== requirement.auditorBaseUrl + ) { + return false; + } + if ( + requirement.auditorPub != null && + auditor.auditor_pub !== requirement.auditorPub + ) { + return false; + } + if (requirement.denomPubHash == null) { + return auditor.denomination_keys.length > 0; + } + return auditor.denomination_keys.some( + (x) => x.denom_pub_h === requirement.denomPubHash, + ); +} + +export function hasVerifiedAuditorTrust( + auditors: WalletExchangeAuditor[], + requirement: AuditorTrustRequirement, +): boolean { + return auditors.some((auditor) => + auditorProvidesVerifiedTrust(auditor, requirement), + ); +} diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts @@ -101,6 +101,7 @@ import { WalletExchangeDetails, } from "./db-common.js"; import { getEffectiveExchangeType } from "./builtin-exchanges.js"; +import { hasVerifiedAuditorTrust } from "./auditorTrust.js"; import {} from "./db-indexeddb.js"; import { WalletDbTransaction } from "./dbtx.js"; import { @@ -216,6 +217,7 @@ class BalancesStore { currency: string, exchangeBaseUrl: string, exchangeMasterPub?: string, + denomPubHash?: string, ): Promise<WalletBalance> { // The currency and the key are part of the cache key, not just the URL: // one exchange can hold funds under a key or a currency it has since @@ -224,13 +226,14 @@ class BalancesStore { // in different currencies would be added together. const cacheKey = `${exchangeBaseUrl}\u0000${currency}\u0000${ exchangeMasterPub ?? "" - }`; + }\u0000${denomPubHash ?? ""}`; let scopeInfo: ScopeInfo | undefined = this.exchangeScopeCache[cacheKey]; if (!scopeInfo) { scopeInfo = await this.resolveScope( exchangeBaseUrl, currency, exchangeMasterPub, + denomPubHash, ); this.exchangeScopeCache[cacheKey] = scopeInfo; } @@ -265,6 +268,7 @@ class BalancesStore { exchangeBaseUrl: string, currency: string, exchangeMasterPub?: string, + denomPubHash?: string, ): Promise<ScopeInfo> { const det = await this.tx.getExchangeDetails(exchangeBaseUrl); if ( @@ -280,15 +284,25 @@ class BalancesStore { masterPub: exchangeMasterPub ?? det.masterPublicKey, }; } - return await this.tx.getExchangeScopeInfo(exchangeBaseUrl, currency); + return await this.tx.getExchangeScopeInfo( + exchangeBaseUrl, + currency, + denomPubHash, + ); } async addZero( currency: string, exchangeBaseUrl: string, exchangeMasterPub?: string, + denomPubHash?: string, ): Promise<void> { - await this.initBalance(currency, exchangeBaseUrl, exchangeMasterPub); + await this.initBalance( + currency, + exchangeBaseUrl, + exchangeMasterPub, + denomPubHash, + ); } async setPeerPaymentsDisabled( @@ -321,11 +335,13 @@ class BalancesStore { exchangeBaseUrl: string, amount: AmountLike, exchangeMasterPub?: string, + denomPubHash?: string, ): Promise<void> { const b = await this.initBalance( currency, exchangeBaseUrl, exchangeMasterPub, + denomPubHash, ); b.available = Amounts.add(b.available, amount).amount; } @@ -518,13 +534,19 @@ export async function getBalancesInsideTransaction( // coin was made available. const denomKey = denomRefKey(ca); const masterPub = masterPubByDenom.get(denomKey) ?? ca.exchangeMasterPub; - await balanceStore.addZero(ca.currency, ca.exchangeBaseUrl, masterPub); + await balanceStore.addZero( + ca.currency, + ca.exchangeBaseUrl, + masterPub, + ca.denomPubHash, + ); if (count > 0) { await balanceStore.addAvailable( ca.currency, ca.exchangeBaseUrl, Amounts.mult(ca.value, count).amount, masterPub, + ca.denomPubHash, ); } } @@ -973,12 +995,13 @@ export class PaymentBalanceSnapshot { tx: WalletDbTransaction, exchangeBaseUrl: string, scope: ScopeInfo, + denomPubHash?: string, ): Promise<boolean> { - const key = `${exchangeBaseUrl}\0${j2s(scope)}`; + const key = `${exchangeBaseUrl}\0${j2s(scope)}\0${denomPubHash ?? ""}`; if (!this.scopes.has(key)) { this.scopes.set( key, - await tx.checkExchangeInScope(exchangeBaseUrl, scope), + await tx.checkExchangeInScope(exchangeBaseUrl, scope, denomPubHash), ); } return this.scopes.get(key)!; @@ -1036,6 +1059,7 @@ export async function getPaymentBalanceDetailsInTx( tx, ca.exchangeBaseUrl, req.restrictSenderScope, + ca.denomPubHash, )) ) { continue; @@ -1109,11 +1133,15 @@ export async function getPaymentBalanceDetailsInTx( } for (const acceptedAuditor of req.restrictReceiverExchanges.auditors) { - for (const exchangeAuditor of wireDetails.auditors) { - if (acceptedAuditor.auditorBaseUrl === exchangeAuditor.auditor_url) { - merchantExchangeAuditorAcceptable = true; - break; - } + if ( + hasVerifiedAuditorTrust(wireDetails.auditors, { + auditorBaseUrl: acceptedAuditor.auditorBaseUrl, + auditorPub: acceptedAuditor.auditorPub, + denomPubHash: ca.denomPubHash, + }) + ) { + merchantExchangeAuditorAcceptable = true; + break; } } } @@ -1121,6 +1149,7 @@ export async function getPaymentBalanceDetailsInTx( const merchantExchangeAcceptable = checkExchangeAccepted( wireDetails, req.restrictReceiverExchanges, + ca.denomPubHash, ).accepted; const merchantExchangeDepositable = merchantExchangeAcceptable && wireOkay; diff --git a/packages/taler-wallet-core/src/coinSelection.test.ts b/packages/taler-wallet-core/src/coinSelection.test.ts @@ -1559,6 +1559,70 @@ test("receiver acceptance identifies a master key mismatch at the same URL", () ]); }); +test("auditor acceptance is verified separately for every denomination", () => { + const exchange = { + exchangeBaseUrl: "https://exchange.example/", + masterPublicKey: "EXCHANGE_MASTER_PUB", + auditors: [ + { + auditor_url: "https://auditor.example/", + auditor_pub: "AUDITOR_PUB", + auditor_name: "Example Auditor", + denomination_keys: [{ denom_pub_h: "DENOM_A", auditor_sig: "SIG_A" }], + walletAuditorSignaturesVerified: true as const, + }, + ], + }; + const restrictions = { + exchanges: [], + auditors: [ + { + auditorBaseUrl: "https://auditor.example/", + auditorPub: "AUDITOR_PUB", + }, + ], + }; + + assert.strictEqual( + checkExchangeAccepted(exchange, restrictions, "DENOM_A").accepted, + true, + ); + assert.strictEqual( + checkExchangeAccepted(exchange, restrictions, "DENOM_B").accepted, + false, + ); + assert.strictEqual( + checkExchangeAccepted( + { + ...exchange, + auditors: exchange.auditors.map( + ({ walletAuditorSignaturesVerified: _untrusted, ...a }) => a, + ), + }, + restrictions, + "DENOM_A", + ).accepted, + false, + "legacy unverified database entries must fail closed", + ); + assert.strictEqual( + checkExchangeAccepted( + exchange, + { + exchanges: [], + auditors: [ + { + auditorBaseUrl: "https://substituted.example/", + auditorPub: "AUDITOR_PUB", + }, + ], + }, + "DENOM_A", + ).accepted, + false, + ); +}); + // The tests below pin the "legacy-2024" algorithm, which reproduces how the // wallet selected coins in 2024. They deliberately mirror scenarios that the // default algorithm now handles differently, so a change to either one shows diff --git a/packages/taler-wallet-core/src/coinSelection.ts b/packages/taler-wallet-core/src/coinSelection.ts @@ -77,6 +77,7 @@ import { DenominationVerificationStatus, WalletDenomination, } from "./db-common.js"; +import { hasVerifiedAuditorTrust } from "./auditorTrust.js"; import { checkExchangeInScopeTx, ExchangeDetails, @@ -1894,6 +1895,7 @@ export function checkExchangeAccepted( "masterPublicKey" | "exchangeBaseUrl" | "auditors" >, exchangeRestrictions: ExchangeRestrictionSpec | undefined, + denomPubHash?: string, ): ExchangeAcceptanceResult { if (!exchangeRestrictions) { return { @@ -1918,11 +1920,15 @@ export function checkExchangeAccepted( } } for (const allowedAuditor of exchangeRestrictions.auditors) { - for (const providedAuditor of exchangeDetails.auditors) { - if (allowedAuditor.auditorPub === providedAuditor.auditor_pub) { - acceptedByAuditorPub = true; - break; - } + if ( + hasVerifiedAuditorTrust(exchangeDetails.auditors, { + auditorBaseUrl: allowedAuditor.auditorBaseUrl, + auditorPub: allowedAuditor.auditorPub, + denomPubHash, + }) + ) { + acceptedByAuditorPub = true; + break; } } const accepted = acceptedByExchangePub || acceptedByAuditorPub; @@ -2084,8 +2090,7 @@ async function selectPayCandidates( candidateDenoms.map((denom) => [denomRefKey(denom), denom]), ); - // Save denoms with how many coins are available - // FIXME: Check that the individual denomination is audited! + // Save denoms with how many coins are available. for (const coinAvail of myExchangeCoins) { const denom = candidateDenomsByRef.get(denomRefKey(coinAvail)); checkDbInvariant( @@ -2096,6 +2101,29 @@ async function selectPayCandidates( logger.trace("denom is revoked"); continue; } + if ( + !checkExchangeAccepted( + exchangeDetails, + req.restrictExchanges, + denom.denomPubHash, + ).accepted + ) { + logger.trace("denom is not accepted by the receiver"); + continue; + } + if ( + req.restrictScope && + !(await checkExchangeInScopeTx( + tx, + exchange.baseUrl, + req.restrictScope, + exchangeDetails, + denom.denomPubHash, + )) + ) { + logger.trace("denom is outside the requested scope"); + continue; + } if (denom.exchangeMasterPub === exchangeDetails.masterPublicKey) { if (!denom.isOffered) { logger.trace("denom is unoffered"); diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.test.ts b/packages/taler-wallet-core/src/crypto/cryptoImplementation.test.ts @@ -31,6 +31,7 @@ import { EddsaSignatureString, ExchangeSignKeyJson, GlobalFees, + hash, hashTruncate32, MerchantContractOutputType, MerchantContractTermsV1, @@ -536,6 +537,70 @@ test("a garbled signature is rejected", async () => { assert.strictEqual(res.valid, false); }); +test("auditor denomination signatures cover the complete denomination", async () => { + const auditor = createEddsaKeyPair(); + const auditorBaseUrl = "https://auditor.example/"; + const denomPubHash = encodeCrock(hash(stringToBytes("denomination"))); + const value = Amounts.parseOrThrow("TESTKUDOS:5"); + const feeWithdraw = Amounts.parseOrThrow("TESTKUDOS:0.01"); + const feeDeposit = Amounts.parseOrThrow("TESTKUDOS:0.02"); + const feeRefresh = Amounts.parseOrThrow("TESTKUDOS:0.03"); + const feeRefund = Amounts.parseOrThrow("TESTKUDOS:0.04"); + const stampStart = t(1000); + const stampExpireWithdraw = t(2000); + const stampExpireDeposit = t(3000); + const stampExpireLegal = t(4000); + const preimage = buildSigPS(TalerSignaturePurpose.AUDITOR_EXCHANGE_KEYS) + .put(hash(stringToBytes(auditorBaseUrl + "\0"))) + .put(decodeCrock(masterPub)) + .put(timestampRoundedToBuffer(stampStart)) + .put(timestampRoundedToBuffer(stampExpireWithdraw)) + .put(timestampRoundedToBuffer(stampExpireDeposit)) + .put(timestampRoundedToBuffer(stampExpireLegal)) + .put(bufferFromAmount(value)) + .put(bufferFromAmount(feeWithdraw)) + .put(bufferFromAmount(feeDeposit)) + .put(bufferFromAmount(feeRefresh)) + .put(bufferFromAmount(feeRefund)) + .put(decodeCrock(denomPubHash)) + .build(); + const req = { + auditorBaseUrl, + auditorPub: encodeCrock(auditor.eddsaPub), + auditorSig: encodeCrock(eddsaSign(preimage, auditor.eddsaPriv)), + masterPub, + denomPubHash, + value, + feeWithdraw, + feeDeposit, + feeRefresh, + feeRefund, + stampStart, + stampExpireWithdraw, + stampExpireDeposit, + stampExpireLegal, + }; + + assert.deepStrictEqual( + await nativeCryptoR.isValidAuditorDenom(nativeCryptoR, req), + { valid: true }, + ); + assert.deepStrictEqual( + await nativeCryptoR.isValidAuditorDenom(nativeCryptoR, { + ...req, + feeDeposit: Amounts.parseOrThrow("TESTKUDOS:0.021"), + }), + { valid: false }, + ); + assert.deepStrictEqual( + await nativeCryptoR.isValidAuditorDenom(nativeCryptoR, { + ...req, + auditorBaseUrl: "https://substituted.example/", + }), + { valid: false }, + ); +}); + function signedGlobalFees( over: Omit<GlobalFees, "master_sig">, claimed: Partial<GlobalFees> = {}, diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts @@ -296,6 +296,10 @@ export interface TalerCryptoInterface { isValidDenom(req: DenominationValidationRequest): Promise<ValidationResult>; + isValidAuditorDenom( + req: AuditorDenominationValidationRequest, + ): Promise<ValidationResult>; + isValidWireAccount( req: WireAccountValidationRequest, ): Promise<ValidationResult>; @@ -528,6 +532,11 @@ export const nullCrypto: TalerCryptoInterface = { ): Promise<ValidationResult> { throw new Error("Function not implemented."); }, + isValidAuditorDenom: function ( + req: AuditorDenominationValidationRequest, + ): Promise<ValidationResult> { + throw new Error("Function not implemented."); + }, isValidWireAccount: function ( req: WireAccountValidationRequest, ): Promise<ValidationResult> { @@ -910,6 +919,15 @@ export interface DenominationValidationRequest { masterSig: string; } +export interface AuditorDenominationValidationRequest extends Omit< + DenominationValidationRequest, + "masterSig" +> { + auditorBaseUrl: string; + auditorPub: string; + auditorSig: string; +} + export function collectSlateTokenEnvelopes( contractTerms: MerchantContractTermsV1, choiceIndex: number, @@ -1786,6 +1804,39 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { return { valid: res }; }, + /** + * Check an auditor's affirmation of one denomination. Unlike the exchange + * master signature, this preimage starts with the hash of the auditor URL + * (including its C string terminator), as specified by + * TALER_ExchangeKeyValidityPS. + */ + async isValidAuditorDenom( + tci: TalerCryptoInterfaceR, + req: AuditorDenominationValidationRequest, + ): Promise<ValidationResult> { + const p = buildSigPS(TalerSignaturePurpose.AUDITOR_EXCHANGE_KEYS) + .put(hash(stringToBytes(req.auditorBaseUrl + "\0"))) + .put(decodeCrock(req.masterPub)) + .put(timestampRoundedToBuffer(req.stampStart)) + .put(timestampRoundedToBuffer(req.stampExpireWithdraw)) + .put(timestampRoundedToBuffer(req.stampExpireDeposit)) + .put(timestampRoundedToBuffer(req.stampExpireLegal)) + .put(bufferFromAmount(req.value)) + .put(bufferFromAmount(req.feeWithdraw)) + .put(bufferFromAmount(req.feeDeposit)) + .put(bufferFromAmount(req.feeRefresh)) + .put(bufferFromAmount(req.feeRefund)) + .put(decodeCrock(req.denomPubHash)) + .build(); + return { + valid: eddsaVerify( + p, + decodeCrock(req.auditorSig), + decodeCrock(req.auditorPub), + ), + }; + }, + async isValidWireAccount( tci: TalerCryptoInterfaceR, req: WireAccountValidationRequest, diff --git a/packages/taler-wallet-core/src/db-common.ts b/packages/taler-wallet-core/src/db-common.ts @@ -1360,7 +1360,7 @@ export interface WalletExchangeDetails { /** * Auditors (partially) auditing the exchange. */ - auditors: ExchangeAuditor[]; + auditors: WalletExchangeAuditor[]; /** * Last observed protocol version. @@ -1401,6 +1401,18 @@ export interface WalletExchangeDetails { defaultPeerPushExpiration: TalerProtocolDuration | undefined; } +/** + * Auditor metadata persisted by the wallet. + * + * Older wallet versions stored the exchange's unverified /keys payload here. + * Only entries carrying this marker have had every remaining denomination + * signature checked by wallet-core. Keeping the marker inside the existing + * JSON column makes old databases fail closed without a schema migration. + */ +export interface WalletExchangeAuditor extends ExchangeAuditor { + walletAuditorSignaturesVerified?: true; +} + export interface WalletDenomLossEvent { denomLossEventId: string; currency: string; diff --git a/packages/taler-wallet-core/src/dbtx-conformance-cases.ts b/packages/taler-wallet-core/src/dbtx-conformance-cases.ts @@ -30,6 +30,7 @@ import { encodeCrock, MerchantContractTokenKind, RefreshReason, + ScopeType, DenomKeyType, ExchangeEntrySource, TalerPreciseTimestamp, @@ -3148,6 +3149,88 @@ export const conformanceCases: ConformanceCase[] = [ }, { + name: "auditor scope: membership is denomination-specific", + async run(t, runner) { + const exchangeBaseUrl = "https://audited-exchange.example/"; + const auditorBaseUrl = "https://auditor.example/"; + const auditorPub = ck("scope-auditor"); + await runner.runReadWriteTx(async (tx) => { + const details = makeExchangeDetails(exchangeBaseUrl, "scope-master"); + details.auditors = [ + { + auditor_url: auditorBaseUrl, + auditor_pub: auditorPub, + auditor_name: "Scope Auditor", + denomination_keys: [ + { + denom_pub_h: ckh("scope-denom-a"), + auditor_sig: ck("scope-auditor-sig"), + }, + ], + walletAuditorSignaturesVerified: true, + }, + ]; + await tx.upsertExchangeDetails(details); + const exchange = makeExchange(exchangeBaseUrl); + exchange.detailsPointer = { + masterPublicKey: details.masterPublicKey, + currency: details.currency, + updateClock: tsPrecise(1), + }; + await tx.upsertExchange(exchange); + await tx.upsertGlobalCurrencyAuditor({ + currency: details.currency, + auditorBaseUrl, + auditorPub, + }); + }); + + const scope = { + type: ScopeType.Auditor as const, + currency: "TESTKUDOS", + url: auditorBaseUrl, + }; + const results = await runner.runReadWriteTx(async (tx) => ({ + exact: await tx.checkExchangeInScope( + exchangeBaseUrl, + scope, + ckh("scope-denom-a"), + ), + other: await tx.checkExchangeInScope( + exchangeBaseUrl, + scope, + ckh("scope-denom-b"), + ), + any: await tx.checkExchangeInScope(exchangeBaseUrl, scope), + exactScope: await tx.getExchangeScopeInfo( + exchangeBaseUrl, + "TESTKUDOS", + ckh("scope-denom-a"), + ), + otherScope: await tx.getExchangeScopeInfo( + exchangeBaseUrl, + "TESTKUDOS", + ckh("scope-denom-b"), + ), + exchangeScope: await tx.getExchangeScopeInfo( + exchangeBaseUrl, + "TESTKUDOS", + ), + })); + t.equal(results.exact, true); + t.equal(results.other, false); + t.equal(results.any, true, "exchange prefilters use existential scope"); + t.equal(results.exactScope.type, ScopeType.Auditor); + t.equal(results.otherScope.type, ScopeType.Exchange); + t.equal( + results.exchangeScope.type, + ScopeType.Exchange, + "an exchange without a denomination context is not wholly audited", + ); + }, + }, + + { name: "transaction meta: the timestamp cursor cannot separate a tie", async run(t, runner) { // Timestamps are not unique, and the pagination cursor is a bound on diff --git a/packages/taler-wallet-core/src/dbtx-indexeddb.ts b/packages/taler-wallet-core/src/dbtx-indexeddb.ts @@ -100,6 +100,7 @@ import type { WalletDbMigrationPage, WalletDenomRef, } from "./dbtx.js"; +import { auditorProvidesVerifiedTrust } from "./auditorTrust.js"; function getActiveKeyRange() { return GlobalIDB.KeyRange.bound( @@ -2055,6 +2056,7 @@ export class IdbWalletTransaction implements WalletDbTransaction { async checkExchangeInScope( exchangeBaseUrl: string, scope: ScopeInfo, + denomPubHash?: string, ): Promise<boolean> { switch (scope.type) { case ScopeType.Exchange: { @@ -2075,8 +2077,34 @@ export class IdbWalletTransaction implements WalletDbTransaction { ); return gr != null; } - case ScopeType.Auditor: - throw Error("auditor scope not supported yet"); + case ScopeType.Auditor: { + const exchangeDetails = await this.getExchangeDetails(exchangeBaseUrl); + if (!exchangeDetails || exchangeDetails.currency !== scope.currency) { + return false; + } + for (const auditor of exchangeDetails.auditors) { + if ( + !auditorProvidesVerifiedTrust(auditor, { + auditorBaseUrl: scope.url, + denomPubHash, + }) + ) { + continue; + } + const configured = + await this.tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get( + [ + exchangeDetails.currency, + auditor.auditor_url, + auditor.auditor_pub, + ], + ); + if (configured) { + return true; + } + } + return false; + } case ScopeType.ExchangeLegacyKeys: // See checkExchangeInScopeGeneric: an entry stands for its current // key set, which is never a superseded one. @@ -2089,6 +2117,7 @@ export class IdbWalletTransaction implements WalletDbTransaction { async getExchangeScopeInfo( exchangeBaseUrl: string, currency: string, + denomPubHash?: string, ): Promise<ScopeInfo> { const det = await this.getExchangeDetails(exchangeBaseUrl); if (!det) { @@ -2110,7 +2139,10 @@ export class IdbWalletTransaction implements WalletDbTransaction { type: ScopeType.Global, }; } else { - for (const aud of det.auditors) { + for (const aud of denomPubHash == null ? [] : det.auditors) { + if (!auditorProvidesVerifiedTrust(aud, { denomPubHash })) { + continue; + } const globalAuditorRec = await this.tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get( [det.currency, aud.auditor_url, aud.auditor_pub], diff --git a/packages/taler-wallet-core/src/dbtx-shared.ts b/packages/taler-wallet-core/src/dbtx-shared.ts @@ -26,6 +26,7 @@ import { assertUnreachable, ScopeInfo, ScopeType } from "@gnu-taler/taler-util"; import { WalletDbTransaction } from "./dbtx.js"; import { PurchaseStatus, WalletPurchase } from "./db-common.js"; +import { auditorProvidesVerifiedTrust } from "./auditorTrust.js"; /** * Does the exchange fall within the given scope? @@ -34,6 +35,7 @@ export async function checkExchangeInScopeGeneric( tx: WalletDbTransaction, exchangeBaseUrl: string, scope: ScopeInfo, + denomPubHash?: string, ): Promise<boolean> { switch (scope.type) { case ScopeType.Exchange: @@ -50,8 +52,32 @@ export async function checkExchangeInScopeGeneric( ); return gr != null; } - case ScopeType.Auditor: - throw Error("auditor scope not supported yet"); + case ScopeType.Auditor: { + const details = await tx.getExchangeDetails(exchangeBaseUrl); + if (!details || details.currency !== scope.currency) { + return false; + } + for (const auditor of details.auditors) { + if ( + !auditorProvidesVerifiedTrust(auditor, { + auditorBaseUrl: scope.url, + denomPubHash, + }) + ) { + continue; + } + if ( + await tx.getGlobalCurrencyAuditor( + details.currency, + auditor.auditor_url, + auditor.auditor_pub, + ) + ) { + return true; + } + } + return false; + } case ScopeType.ExchangeLegacyKeys: // Asked of an exchange entry, which always stands for the key set it // currently uses. That is by definition not a superseded one, so the @@ -69,6 +95,7 @@ export async function getExchangeScopeInfoGeneric( tx: WalletDbTransaction, exchangeBaseUrl: string, currency: string, + denomPubHash?: string, ): Promise<ScopeInfo> { const det = await tx.getExchangeDetails(exchangeBaseUrl); if (!det) { @@ -89,7 +116,14 @@ export async function getExchangeScopeInfoGeneric( type: ScopeType.Global, }; } - for (const aud of det.auditors) { + for (const aud of denomPubHash == null ? [] : det.auditors) { + if ( + !auditorProvidesVerifiedTrust(aud, { + denomPubHash, + }) + ) { + continue; + } const globalAuditorRec = await tx.getGlobalCurrencyAuditor( det.currency, aud.auditor_url, diff --git a/packages/taler-wallet-core/src/dbtx-sqlite.ts b/packages/taler-wallet-core/src/dbtx-sqlite.ts @@ -4989,15 +4989,27 @@ export class SqliteWalletTransaction implements WalletDbTransaction { async checkExchangeInScope( exchangeBaseUrl: string, scope: ScopeInfo, + denomPubHash?: string, ): Promise<boolean> { - return await checkExchangeInScopeGeneric(this, exchangeBaseUrl, scope); + return await checkExchangeInScopeGeneric( + this, + exchangeBaseUrl, + scope, + denomPubHash, + ); } async getExchangeScopeInfo( exchangeBaseUrl: string, currency: string, + denomPubHash?: string, ): Promise<ScopeInfo> { - return await getExchangeScopeInfoGeneric(this, exchangeBaseUrl, currency); + return await getExchangeScopeInfoGeneric( + this, + exchangeBaseUrl, + currency, + denomPubHash, + ); } // ---------------------------------------------------- bank accounts diff --git a/packages/taler-wallet-core/src/dbtx.ts b/packages/taler-wallet-core/src/dbtx.ts @@ -1254,16 +1254,27 @@ export interface WalletDbTransaction { exchangeBaseUrl: string, ): Promise<WalletExchangeDetails | undefined>; - /** Check whether an exchange falls within a currency scope. */ + /** + * Check whether an exchange falls within a currency scope. + * + * For auditor scopes, a denomination hash requests exact membership. When + * omitted, the check is existential and is only suitable for candidate + * exchange filtering. + */ checkExchangeInScope( exchangeBaseUrl: string, scope: ScopeInfo, + denomPubHash?: string, ): Promise<boolean>; - /** Compute the scope (global, auditor or exchange) an exchange belongs to. */ + /** + * Compute the scope (global, auditor or exchange) for exchange funds. + * Auditor scope is returned only with a specifically attested denomination. + */ getExchangeScopeInfo( exchangeBaseUrl: string, currency: string, + denomPubHash?: string, ): Promise<ScopeInfo>; /** diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts @@ -156,6 +156,7 @@ import { WalletDenomination, WalletDenominationFamily, WalletCoin, + WalletExchangeAuditor, WalletExchangeDetails, WalletExchangeDetailsPointer, WalletExchangeEntry, @@ -175,6 +176,7 @@ import { selectBestForOverlappingDenominations, selectMinimumFee, } from "./denominations.js"; +import { hasVerifiedAuditorTrust } from "./auditorTrust.js"; import { DepositTransactionContext } from "./deposits.js"; import { PayMerchantTransactionContext, @@ -364,18 +366,52 @@ export async function getScopeForAllCoins( tx: WalletDbTransaction, coinPubs: string[], ): Promise<ScopeInfo[]> { - let exchangeSet = new Set<string>(); + const scopes: ScopeInfo[] = []; + const scopeSet = new Set<string>(); const coins = await tx.getCoinsByPubs(coinPubs); const coinsByPub = new Map(coins.map((coin) => [coin.coinPub, coin])); + const denoms = await tx.getDenominationsByRefs(coins); + const denomsByRef = new Map(denoms.map((d) => [denomRefKey(d), d])); + const exchangeBaseUrls = [...new Set(coins.map((c) => c.exchangeBaseUrl))]; + const detailsByUrl = new Map<string, WalletExchangeDetails | undefined>(); + for (const url of exchangeBaseUrls) { + detailsByUrl.set(url, await getExchangeRecordsInternal(tx, url)); + } for (const pub of coinPubs) { const coin = coinsByPub.get(pub); if (!coin) { logger.warn(`coin ${coinPubs} not found, unable to compute full scope`); continue; } - exchangeSet.add(coin.exchangeBaseUrl); + const denom = denomsByRef.get(denomRefKey(coin)); + const details = detailsByUrl.get(coin.exchangeBaseUrl); + let scope: ScopeInfo; + if ( + details && + coin.exchangeMasterPub !== details.masterPublicKey + ) { + scope = { + type: ScopeType.ExchangeLegacyKeys, + currency: denom?.currency ?? details.currency, + url: coin.exchangeBaseUrl, + masterPub: coin.exchangeMasterPub, + }; + } else if (details) { + scope = await internalGetExchangeScopeInfo( + tx, + details, + coin.denomPubHash, + ); + } else { + continue; + } + const scopeKey = stringifyScopeInfo(scope); + if (!scopeSet.has(scopeKey)) { + scopeSet.add(scopeKey); + scopes.push(scope); + } } - return await getScopeForAllExchanges(tx, [...exchangeSet]); + return scopes; } /** @@ -404,18 +440,20 @@ export async function getScopeForAllExchanges( export async function getExchangeScopeInfoOrUndefined( tx: WalletDbTransaction, exchangeBaseUrl: string, + denomPubHash?: string, ): Promise<ScopeInfo | undefined> { const det = await getExchangeRecordsInternal(tx, exchangeBaseUrl); if (!det) { return undefined; } - return internalGetExchangeScopeInfo(tx, det); + return internalGetExchangeScopeInfo(tx, det, denomPubHash); } export async function getExchangeScopeInfo( tx: WalletDbTransaction, exchangeBaseUrl: string, currency: string, + denomPubHash?: string, ): Promise<ScopeInfo> { const det = await getExchangeRecordsInternal(tx, exchangeBaseUrl); if (!det) { @@ -425,12 +463,13 @@ export async function getExchangeScopeInfo( url: exchangeBaseUrl, }; } - return internalGetExchangeScopeInfo(tx, det); + return internalGetExchangeScopeInfo(tx, det, denomPubHash); } async function internalGetExchangeScopeInfo( tx: WalletDbTransaction, exchangeDetails: WalletExchangeDetails, + denomPubHash?: string, ): Promise<ScopeInfo> { const globalExchangeRec = await tx.getGlobalCurrencyExchange( exchangeDetails.currency, @@ -443,7 +482,10 @@ async function internalGetExchangeScopeInfo( type: ScopeType.Global, }; } else { - for (const aud of exchangeDetails.auditors) { + for (const aud of denomPubHash == null ? [] : exchangeDetails.auditors) { + if (!hasVerifiedAuditorTrust([aud], { denomPubHash })) { + continue; + } const globalAuditorRec = await tx.getGlobalCurrencyAuditor( exchangeDetails.currency, aud.auditor_url, @@ -615,7 +657,7 @@ export interface ExchangeDetails { masterPublicKey: EddsaPublicKeyString; wireInfo: WireInfo; exchangeBaseUrl: string; - auditors: ExchangeAuditor[]; + auditors: WalletExchangeAuditor[]; globalFees: ExchangeGlobalFees[]; reserveClosingDelay: TalerProtocolDuration; defaultPeerPushExpiration?: TalerProtocolDuration; @@ -971,6 +1013,140 @@ async function validateSignKeys( } } +/** + * Verify auditor attestations independently for every denomination. + * + * A bad auditor signature is not an exchange /keys failure: it merely means + * that this auditor does not confer trust on that denomination. The returned + * entries contain only verified signatures and carry an internal persistence + * marker so unverified entries from older databases fail closed. + */ +async function validateAuditorDenominations( + wex: WalletExecutionContext, + auditors: ExchangeAuditor[], + denoms: DenominationInfo[], +): Promise<WalletExchangeAuditor[]> { + const denomByHash = new Map(denoms.map((d) => [d.denomPubHash, d])); + const result: WalletExchangeAuditor[] = []; + for (const auditor of auditors) { + const verifiedKeys: ExchangeAuditor["denomination_keys"] = []; + let rejectedCount = 0; + // Bound the number of outstanding worker RPCs. Large /keys responses can + // contain thousands of denominations. + for ( + let offset = 0; + offset < auditor.denomination_keys.length; + offset += 70 + ) { + const batch = auditor.denomination_keys.slice(offset, offset + 70); + const validations = await Promise.all( + batch.map(async (auditorDenom) => { + const denom = denomByHash.get(auditorDenom.denom_pub_h); + if (!denom) { + return false; + } + if (wex.ws.config.testing.insecureTrustExchange) { + return true; + } + try { + const { valid } = await wex.cryptoApi.isValidAuditorDenom({ + auditorBaseUrl: auditor.auditor_url, + auditorPub: auditor.auditor_pub, + auditorSig: auditorDenom.auditor_sig, + denomPubHash: denom.denomPubHash, + masterPub: denom.exchangeMasterPub, + stampStart: denom.stampStart, + stampExpireWithdraw: denom.stampExpireWithdraw, + stampExpireDeposit: denom.stampExpireDeposit, + stampExpireLegal: denom.stampExpireLegal, + value: Amounts.parseOrThrow(denom.value), + feeWithdraw: Amounts.parseOrThrow(denom.feeWithdraw), + feeDeposit: Amounts.parseOrThrow(denom.feeDeposit), + feeRefresh: Amounts.parseOrThrow(denom.feeRefresh), + feeRefund: Amounts.parseOrThrow(denom.feeRefund), + }); + return valid; + } catch { + return false; + } + }), + ); + for (let i = 0; i < batch.length; i++) { + if (validations[i]) { + verifiedKeys.push(batch[i]); + } else { + rejectedCount++; + } + } + } + if (rejectedCount > 0) { + logger.warn( + `ignoring ${rejectedCount} invalid auditor denomination ` + + `signature(s) from ${auditor.auditor_url}`, + ); + } + if (verifiedKeys.length > 0) { + result.push({ + ...auditor, + denomination_keys: verifiedKeys, + walletAuditorSignaturesVerified: true, + }); + } + } + return result; +} + +/** Preserve verified attestations outside a cherry-picked response. */ +function mergeCherryPickedAuditors( + current: WalletExchangeAuditor[], + previous: WalletExchangeAuditor[], + coveredDenomHashes: Set<string>, +): WalletExchangeAuditor[] { + const merged = new Map<string, WalletExchangeAuditor>(); + const add = ( + auditor: WalletExchangeAuditor, + keys: ExchangeAuditor["denomination_keys"], + ): void => { + if (auditor.walletAuditorSignaturesVerified !== true || keys.length === 0) { + return; + } + const id = `${auditor.auditor_url}\0${auditor.auditor_pub}`; + const existing = merged.get(id); + if (!existing) { + merged.set(id, { + ...auditor, + denomination_keys: [...keys], + walletAuditorSignaturesVerified: true, + }); + return; + } + const known = new Set( + existing.denomination_keys.map( + (x) => `${x.denom_pub_h}\0${x.auditor_sig}`, + ), + ); + for (const key of keys) { + const id = `${key.denom_pub_h}\0${key.auditor_sig}`; + if (!known.has(id)) { + known.add(id); + existing.denomination_keys.push(key); + } + } + }; + for (const auditor of current) { + add(auditor, auditor.denomination_keys); + } + for (const auditor of previous) { + add( + auditor, + auditor.denomination_keys.filter( + (x) => !coveredDenomHashes.has(x.denom_pub_h), + ), + ); + } + return [...merged.values()]; +} + async function validateGlobalFees( wex: WalletExecutionContext, fees: GlobalFees[], @@ -2373,6 +2549,12 @@ export async function updateExchangeFromUrlHandler( } } + const validatedAuditors = await validateAuditorDenominations( + wex, + keysInfo.auditors, + denomInfos, + ); + const taskRes = await wex.runWalletDbTx(async (tx) => { const r = await tx.getExchange(exchangeBaseUrl); if (!r) { @@ -2456,8 +2638,18 @@ export async function updateExchangeFromUrlHandler( } delete r.unavailableReason; + const persistedAuditors = + cherryPicked && + existingDetails?.masterPublicKey === keysInfo.master_public_key && + existingDetails.currency === keysInfo.currency + ? mergeCherryPickedAuditors( + validatedAuditors, + existingDetails.auditors, + currentDenomSet, + ) + : validatedAuditors; const newDetails: WalletExchangeDetails = { - auditors: keysInfo.auditors, + auditors: persistedAuditors, currency: keysInfo.currency, masterPublicKey: keysInfo.master_public_key, protocolVersionRange: keysInfo.version, @@ -3882,7 +4074,12 @@ export async function getExchangeDetailedInfo( exchangeBaseUrl: ex.baseUrl, currency, paytoUris: exchangeDetails.wireInfo.accounts.map((x) => x.payto_uri), - auditors: exchangeDetails.auditors, + auditors: exchangeDetails.auditors.map((auditor) => ({ + auditor_pub: auditor.auditor_pub, + auditor_url: auditor.auditor_url, + auditor_name: auditor.auditor_name, + denomination_keys: auditor.denomination_keys, + })), wireInfo: exchangeDetails.wireInfo, globalFees: exchangeDetails.globalFees, }, @@ -4956,7 +5153,11 @@ export async function checkExchangeInScopeTx( tx: WalletDbTransaction, exchangeBaseUrl: string, scope: ScopeInfo, - knownDetails?: Pick<ExchangeDetails, "currency" | "masterPublicKey">, + knownDetails?: Pick< + ExchangeDetails, + "currency" | "masterPublicKey" | "auditors" + >, + denomPubHash?: string, ): Promise<boolean> { logger.trace( `checking if exchange ${exchangeBaseUrl} is in scope ${j2s(scope)}`, @@ -4980,12 +5181,33 @@ export async function checkExchangeInScopeTx( logger.trace(`global currency record: ${j2s(gr)}`); return gr != null; } - case ScopeType.Auditor: - throw TalerError.fromDetail( - TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, - { parameter: "scopeInfo" }, - "the auditor scope is not supported yet", - ); + case ScopeType.Auditor: { + const exchangeDetails = + knownDetails ?? (await getExchangeRecordsInternal(tx, exchangeBaseUrl)); + if (!exchangeDetails || exchangeDetails.currency !== scope.currency) { + return false; + } + for (const auditor of exchangeDetails.auditors) { + if ( + !hasVerifiedAuditorTrust([auditor], { + auditorBaseUrl: scope.url, + denomPubHash, + }) + ) { + continue; + } + if ( + await tx.getGlobalCurrencyAuditor( + exchangeDetails.currency, + auditor.auditor_url, + auditor.auditor_pub, + ) + ) { + return true; + } + } + return false; + } case ScopeType.ExchangeLegacyKeys: // See checkExchangeInScopeGeneric: an entry stands for its current key // set, which is never a superseded one.