taler-typescript-core

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

commit 50c7c31d3ec59a73c43f86277ba0d5c6a7fff984
parent 4ddf87d438a480f6fef6ed373bea58f97ef19f2b
Author: Florian Dold <dold@taler.net>
Date:   Thu, 20 Aug 2026 19:06:46 +0200

wallet-core: drain paginated mailbox responses

Diffstat:
Mpackages/taler-wallet-core/src/mailbox.test.ts | 10+++++++++-
Mpackages/taler-wallet-core/src/mailbox.ts | 173++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
2 files changed, 109 insertions(+), 74 deletions(-)

diff --git a/packages/taler-wallet-core/src/mailbox.test.ts b/packages/taler-wallet-core/src/mailbox.test.ts @@ -23,7 +23,10 @@ import { } from "@gnu-taler/taler-util"; import assert from "node:assert"; import { test } from "node:test"; -import { mailboxMetadataMatchesAddress } from "./mailbox.js"; +import { + mailboxBatchMayHaveMoreMessages, + mailboxMetadataMatchesAddress, +} from "./mailbox.js"; test("mailbox metadata is bound to the requested address", () => { const keys = createEddsaKeyPair(); @@ -45,3 +48,8 @@ test("mailbox metadata is bound to the requested address", () => { false, ); }); + +test("a full mailbox response requires another fetch", () => { + assert.strictEqual(mailboxBatchMayHaveMoreMessages(10, 10), true); + assert.strictEqual(mailboxBatchMayHaveMoreMessages(9, 10), false); +}); diff --git a/packages/taler-wallet-core/src/mailbox.ts b/packages/taler-wallet-core/src/mailbox.ts @@ -62,6 +62,14 @@ import { import { WalletExecutionContext } from "./wallet.js"; const logger = new Logger("mailbox.ts"); +export const MAX_MAILBOX_REFRESH_BATCHES = 100; + +export function mailboxBatchMayHaveMoreMessages( + numMessages: number, + responseLimit: number, +): boolean { + return numMessages >= responseLimit; +} export function mailboxMetadataMatchesAddress( mailboxAddress: string, @@ -294,10 +302,12 @@ export async function refreshMailbox( ); // Refresh message size var message_size; + var message_response_limit; const resConf = await mailboxClient.getConfig(); switch (resConf.case) { case "ok": message_size = resConf.body.message_body_bytes; + message_response_limit = resConf.body.message_response_limit; break; default: throw TalerError.fromDetail( @@ -306,85 +316,102 @@ export async function refreshMailbox( "could not read the configuration of the mailbox service", ); } - const res = await mailboxClient.getMessages({ - hMailbox: mailboxConf.hAddress, - }); - switch (res.case) { - case "ok": { - const hpkeSk: Uint8Array = decodeCrock(mailboxConf.privateEncryptionKey); - if (res.body) { - const messages = res.body.messages; - const now = TalerProtocolTimestamp.now(); - if (messages.byteLength % message_size !== 0) { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, - { - requestUrl: mailboxConf.mailboxBaseUrl, - requestMethod: "GET", - httpStatusCode: HttpStatusCode.Ok, - }, - `mailbox messages response is not a multiple of the message size (${messages.byteLength} % ${message_size} != 0)`, - ); - } - // FIXME: if we have reached the maximum number of - // messages that the service returns at a time, - // we probably have to call again until no more messages to - // download. - const numMessages = messages.byteLength / message_size; - const records: MailboxMessageRecord[] = []; - for (let i = 0; i < numMessages; i++) { - const offset = i * message_size; - const msg: Uint8Array = messages.slice(offset, offset + message_size); - const uri = decryptTalerUriMessage(hpkeSk, msg); - if (!uri) { - logger.warn(`unable to decrypt message number ${i}`); - continue; - } - // Find start of padding - const newMessage = { - originMailboxBaseUrl: mailboxConf.mailboxBaseUrl, - talerUri: uri, - downloadedAt: now, - }; - records.push(newMessage); - await addMailboxMessage(wex, { message: newMessage }); - } - // Message header: 8 byte + 64 byte SHA512 digest to sign - // We hash all messages - const messageBuffer = new ArrayBuffer(16); - const vMsg = new DataView(messageBuffer); - vMsg.setUint32(0, 4 * 4); - vMsg.setUint32(4, TalerSignaturePurpose.MAILBOX_MESSAGES_DELETE); - vMsg.setUint32(8, parseInt(res.body.etag)); - vMsg.setUint32(12, numMessages); - const msgToSign: Uint8Array = new Uint8Array(messageBuffer); - const privateSigningKey: Uint8Array = decodeCrock( - mailboxConf.privateKey, - ); - const signature = eddsaSign(msgToSign, privateSigningKey); - succeedOrThrow( - await mailboxClient.deleteMessages({ - mailboxConf: mailboxConf, - matchIf: res.body.etag, - count: numMessages, - signature: encodeCrock(signature), - }), - ); - return { - messages: records, - }; - } - return { - messages: [], - }; // No new messages; - } - default: + if ( + !Number.isSafeInteger(message_response_limit) || + message_response_limit <= 0 + ) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, + { + requestUrl: mailboxConf.mailboxBaseUrl, + requestMethod: "GET", + httpStatusCode: HttpStatusCode.Ok, + }, + "mailbox service advertised an invalid message response limit", + ); + } + + const hpkeSk: Uint8Array = decodeCrock(mailboxConf.privateEncryptionKey); + const privateSigningKey: Uint8Array = decodeCrock(mailboxConf.privateKey); + const records: MailboxMessageRecord[] = []; + for ( + let batchIndex = 0; + batchIndex < MAX_MAILBOX_REFRESH_BATCHES; + batchIndex++ + ) { + const res = await mailboxClient.getMessages({ + hMailbox: mailboxConf.hAddress, + }); + if (res.case !== "ok") { throw TalerError.fromDetail( TalerErrorCode.WALLET_MAILBOX_UNAVAILABLE, { mailboxBaseUrl: mailboxConf.mailboxBaseUrl }, "the mailbox service did not return the messages", ); + } + + const messages = res.body.messages; + const now = TalerProtocolTimestamp.now(); + if (messages.byteLength % message_size !== 0) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE, + { + requestUrl: mailboxConf.mailboxBaseUrl, + requestMethod: "GET", + httpStatusCode: HttpStatusCode.Ok, + }, + `mailbox messages response is not a multiple of the message size (${messages.byteLength} % ${message_size} != 0)`, + ); + } + const numMessages = messages.byteLength / message_size; + if (numMessages === 0) { + return { messages: records }; + } + + for (let i = 0; i < numMessages; i++) { + const offset = i * message_size; + const msg: Uint8Array = messages.slice(offset, offset + message_size); + const uri = decryptTalerUriMessage(hpkeSk, msg); + if (!uri) { + logger.warn(`unable to decrypt message number ${i}`); + continue; + } + const newMessage = { + originMailboxBaseUrl: mailboxConf.mailboxBaseUrl, + talerUri: uri, + downloadedAt: now, + }; + records.push(newMessage); + await addMailboxMessage(wex, { message: newMessage }); + } + + const messageBuffer = new ArrayBuffer(16); + const vMsg = new DataView(messageBuffer); + vMsg.setUint32(0, 4 * 4); + vMsg.setUint32(4, TalerSignaturePurpose.MAILBOX_MESSAGES_DELETE); + vMsg.setUint32(8, parseInt(res.body.etag)); + vMsg.setUint32(12, numMessages); + const signature = eddsaSign( + new Uint8Array(messageBuffer), + privateSigningKey, + ); + succeedOrThrow( + await mailboxClient.deleteMessages({ + mailboxConf, + matchIf: res.body.etag, + count: numMessages, + signature: encodeCrock(signature), + }), + ); + + if (!mailboxBatchMayHaveMoreMessages(numMessages, message_response_limit)) { + return { messages: records }; + } } + logger.warn( + `mailbox refresh stopped after ${MAX_MAILBOX_REFRESH_BATCHES} full batches`, + ); + return { messages: records }; } function encryptTalerUriMessage(