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:
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[] = [];