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:
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.