taler-typescript-core

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

commit b565a984e84e2f578caf3e268539d6ae8ce3dc57
parent 2efd2e6806ccb1953e9510f9a8edc63d8d0a7eb0
Author: Florian Dold <dold@taler.net>
Date:   Sat, 22 Aug 2026 11:53:49 +0200

wallet-core: repair orphan exchange signing keys

Diffstat:
Mpackages/taler-wallet-core/src/db/indexeddb/fixups.ts | 29+++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/db/migration/converter.test.ts | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 131 insertions(+), 0 deletions(-)

diff --git a/packages/taler-wallet-core/src/db/indexeddb/fixups.ts b/packages/taler-wallet-core/src/db/indexeddb/fixups.ts @@ -61,6 +61,13 @@ export interface FixupDescription { * Fixups *must* be idempotent. */ export const walletDbFixups: FixupDescription[] = [ + // Exchange purging used to delete the details row before querying an + // array-valued index with a scalar key, so the related signing keys were + // not found and survived without their parent. + { + fn: fixup20260822OrphanExchangeSignKeys, + name: "fixup20260822OrphanExchangeSignKeys", + }, // A later repair for duplicate merge-reserve rows whose key material is // identical but where only one row carries KYC metadata. This needs its // own fixup marker: affected databases have already recorded the older, @@ -154,6 +161,28 @@ export const walletDbFixups: FixupDescription[] = [ ]; /** + * Delete signing keys whose exchange details were removed by the old + * IndexedDB exchange-purge implementation. + * + * A signing key describes exactly one details row and carries no independent + * wallet state. Removing it therefore matches the ON DELETE CASCADE + * constraint used by the sqlite backend. + */ +async function fixup20260822OrphanExchangeSignKeys( + tx: WalletIndexedDbTransaction, +): Promise<void> { + await tx.exchangeSignKeys.iter().forEachAsync(async (signKey) => { + if (await tx.exchangeDetails.get(signKey.exchangeDetailsRowId)) { + return; + } + await tx.exchangeSignKeys.delete([ + signKey.exchangeDetailsRowId, + signKey.signkeyPub, + ]); + }); +} + +/** * Copy coin availability into the store keyed by master public key. * * The key comes from the row itself where it was recorded, and otherwise from diff --git a/packages/taler-wallet-core/src/db/migration/converter.test.ts b/packages/taler-wallet-core/src/db/migration/converter.test.ts @@ -35,6 +35,7 @@ import { getRandomBytes, NotificationType, TalerPreciseTimestamp, + TalerProtocolTimestamp, WalletNotification, } from "@gnu-taler/taler-util"; @@ -46,7 +47,10 @@ import { PurchaseStatus, ReserveRecordStatus, timestampPreciseToDb, + timestampProtocolToDb, WalletCoin, + WalletExchangeDetails, + WalletExchangeSignkeys, WalletPeerPushCredit, WalletPurchase, } from "../records.js"; @@ -112,6 +116,104 @@ test("converter: preserves a legacy orphan coin without a master key", async () } }); +test("IndexedDB fixup removes orphan exchange signing keys", async () => { + const src = await makeIdbRunner(); + const key = (): string => encodeCrock(getRandomBytes(32)); + const signature = (): string => encodeCrock(getRandomBytes(64)); + const details = (exchangeBaseUrl: string): WalletExchangeDetails => ({ + exchangeBaseUrl, + masterPublicKey: key(), + currency: "TESTKUDOS", + auditors: [], + protocolVersionRange: "18:0:1", + tinyAmount: "TESTKUDOS:0.01", + reserveClosingDelay: { d_us: 1000 }, + globalFees: [], + wireInfo: { accounts: [], feesForType: {} }, + bankComplianceLanguage: undefined, + defaultPeerPushExpiration: undefined, + }); + const signKey = ( + exchangeDetailsRowId: number, + signkeyPub: string, + ): WalletExchangeSignkeys => ({ + exchangeDetailsRowId, + signkeyPub, + stampStart: timestampProtocolToDb(TalerProtocolTimestamp.fromSeconds(100)), + stampExpire: timestampProtocolToDb(TalerProtocolTimestamp.fromSeconds(200)), + stampEnd: timestampProtocolToDb(TalerProtocolTimestamp.fromSeconds(300)), + masterSig: signature(), + }); + + const orphanPubs = [key(), key()]; + const validPub = key(); + const { orphanDetailsRowId, validDetailsRowId } = await src.runReadWriteTx( + async (tx) => { + const orphanDetailsRowId = await tx.upsertExchangeDetails( + details("https://orphan.example/"), + ); + const validDetailsRowId = await tx.upsertExchangeDetails( + details("https://valid.example/"), + ); + for (const pub of orphanPubs) { + await tx.upsertExchangeSignKey(signKey(orphanDetailsRowId, pub)); + } + await tx.upsertExchangeSignKey(signKey(validDetailsRowId, validPub)); + return { orphanDetailsRowId, validDetailsRowId }; + }, + ); + + const raw = await (src as IdbWalletDbHandle).rawAccess(); + await raw.runAllStoresReadWriteTx({}, async (tx) => { + // Bypass the current DAL cascade to reproduce the legacy purge bug. + await tx.exchangeDetails.delete(orphanDetailsRowId); + await tx.fixups.delete("fixup20260822OrphanExchangeSignKeys"); + }); + + const rejectedDst = await makeSqliteRunner(); + try { + await assert.rejects( + () => convertWalletDb(src, rejectedDst), + new RegExp( + `signing key references missing exchange details ${orphanDetailsRowId}`, + ), + ); + } finally { + await rejectedDst.close(); + } + + await applyFixups(raw); + let repaired = await src.runReadWriteTx((tx) => tx.listAllExchangeSignKeys()); + assert.deepStrictEqual( + repaired.map((x) => [x.exchangeDetailsRowId, x.signkeyPub]), + [[validDetailsRowId, validPub]], + ); + + // Force a second application rather than merely exercising the marker. + await raw.runAllStoresReadWriteTx({}, (tx) => + tx.fixups.delete("fixup20260822OrphanExchangeSignKeys"), + ); + await applyFixups(raw); + repaired = await src.runReadWriteTx((tx) => tx.listAllExchangeSignKeys()); + assert.deepStrictEqual( + repaired.map((x) => [x.exchangeDetailsRowId, x.signkeyPub]), + [[validDetailsRowId, validPub]], + ); + + const dst = await makeSqliteRunner(); + try { + const report = await convertWalletDb(src, dst); + assert.strictEqual(report.copied.exchangeSignKeys, 1); + assert.strictEqual( + (await dst.runReadWriteTx((tx) => tx.listAllExchangeSignKeys())).length, + 1, + ); + } finally { + await src.close(); + await dst.close(); + } +}); + test("converter: IndexedDB to sqlite, populated by the conformance corpus", async () => { const src = await makeIdbRunner(); const progress: WalletNotification[] = [];