taler-typescript-core

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

commit 8db0d0b6d4bca8e06026ef77cbd589cf83a1ce9e
parent 0e505e741d8461816462d9f8e8192e4e7d72ed60
Author: Martin Schanzenbach <schanzen@gnunet.org>
Date:   Thu,  6 Nov 2025 13:50:13 +0100

allow send and receive (and decrypt) messages

Diffstat:
Mpackages/taler-util/src/http-client/mailbox.ts | 8++++----
Mpackages/taler-util/src/taler-crypto.ts | 23++++++++++++++---------
Mpackages/taler-util/src/types-taler-mailbox.ts | 4++--
Mpackages/taler-wallet-core/src/mailbox.ts | 56++++++++++++++++++++++++++------------------------------
Mpackages/taler-wallet-core/src/wallet-api-types.ts | 13+++++++++++++
Mpackages/taler-wallet-core/src/wallet.ts | 8+++++++-
Mpackages/taler-wallet-webextension/src/wallet/Mailbox.tsx | 65+++++++++++++++++++++++++++++++++++++++++++++--------------------
7 files changed, 111 insertions(+), 66 deletions(-)

diff --git a/packages/taler-util/src/http-client/mailbox.ts b/packages/taler-util/src/http-client/mailbox.ts @@ -127,8 +127,8 @@ export class TalerMailboxInstanceHttpClient { | OperationFail<HttpStatusCode.TooManyRequests> | OperationFail<HttpStatusCode.Forbidden> > { - const { h_address, body } = args; - const url = new URL(`${h_address}`, this.baseUrl); + const { h_address: hAddress, body } = args; + const url = new URL(`${hAddress.toUpperCase()}`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "POST", @@ -165,7 +165,7 @@ export class TalerMailboxInstanceHttpClient { | OperationFail<HttpStatusCode.TooManyRequests> >{ const { hMailbox: hMailbox } = args; - const url = new URL(`${hMailbox}`, this.baseUrl); + const url = new URL(`${hMailbox.toUpperCase()}`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "GET", @@ -228,7 +228,7 @@ export class TalerMailboxInstanceHttpClient { | OperationFail<HttpStatusCode.NotFound> | OperationFail<HttpStatusCode.TooManyRequests> >{ - const url = new URL(`keys/${hMailbox}`, this.baseUrl); + const url = new URL(`keys/${hMailbox.toUpperCase()}`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "GET", diff --git a/packages/taler-util/src/taler-crypto.ts b/packages/taler-util/src/taler-crypto.ts @@ -2077,15 +2077,20 @@ export function hpkeOpenOneshot( info: Uint8Array, aad: Uint8Array, ciphertext: Uint8Array): Uint8Array | undefined { - const enc = ciphertext.slice(0, 32); - const ctx = hpkeReceiverSetup(enc, - skR, - info); - const nonce = hpkeComputeNonce(ctx); - return chacha20poly1305_ietf_decrypt(ciphertext.slice(32), - aad, - nonce, - ctx.key); + try { + const enc = ciphertext.slice(0, 32); + const ctx = hpkeReceiverSetup(enc, + skR, + info); + const nonce = hpkeComputeNonce(ctx); + return chacha20poly1305_ietf_decrypt(ciphertext.slice(32), + aad, + nonce, + ctx.key); + } catch (e) { + logger.error("hpkeOpenOneshot failed:" + e) + return undefined; + } } export function hpkeSecretKeyGetPublic( diff --git a/packages/taler-util/src/types-taler-mailbox.ts b/packages/taler-util/src/types-taler-mailbox.ts @@ -106,7 +106,7 @@ buildCodecForObject<MailboxMessageKeys>() .property("signingKeyType", codecForConstString("EdDSA")) .property("encryptionKey", codecForString()) .property("encryptionKeyType", codecForConstString("X25519")) - .property("expiration", codecForNumber()) + .property("expiration", codecForTimestamp) .build("TalerMailboxApi.MailboxMessageKeys"); @@ -136,7 +136,7 @@ export interface MailboxMessageKeys { encryptionKeyType?: string; // Expiration of this mapping. - expiration: number; + expiration: Timestamp; } diff --git a/packages/taler-wallet-core/src/mailbox.ts b/packages/taler-wallet-core/src/mailbox.ts @@ -37,30 +37,21 @@ import { succeedOrThrow, MailboxMessageRecord, TalerProtocolTimestamp, - chacha20poly1305_ietf_decrypt, - OperationOk, - hpkeReceiverSetup, stringToBytes, hpkeOpenOneshot, hpkeCreateSecretKey, SendTalerUriMailboxMessageRequest, hpkeSealOneshot, - TalerError, hpkeSecretKeyGetPublic, MailboxMessageKeys, MailboxMessageKeysUpdateRequest, eddsaSign, Duration, - TalerPreciseTimestamp, AbsoluteTime, } from "@gnu-taler/taler-util"; import { WalletExecutionContext, } from "./wallet.js"; -import { HttpRequestLibrary } from "@gnu-taler/taler-util/http"; -import { OperationType } from "./instructedAmountConversion.js"; -import { AsyncDeflate } from "fflate"; -import { timestampPreciseToDb } from "./db.js"; const logger = new Logger("mailbox.ts"); @@ -171,7 +162,7 @@ export async function updateMailboxKeys( signingKeyType: "EdDSA", // FIXME only supported key type ATM, encryptionKey: encodeCrock(encryptionKey), encryptionKeyType: "X25519", // FIXME only supported encryption key type ATM - expiration: mailboxConf.expiration.t_s, + expiration: mailboxConf.expiration, }; const req: MailboxMessageKeysUpdateRequest = { keys: keys, @@ -179,10 +170,7 @@ export async function updateMailboxKeys( }; const mailboxClient = new TalerMailboxInstanceHttpClient(mailboxConf.mailboxBaseUrl, wex.http); - const resp = await mailboxClient.updateKeys(req); - if (resp.case != "ok") { - throw Error("failed to update mailbox keys"); - } + succeedOrThrow(await mailboxClient.updateKeys(req)); return; } @@ -218,7 +206,7 @@ export async function initMailbox( const privKey = encodeCrock(keys.eddsaPriv); const nowInAYear = AbsoluteTime.addDuration(AbsoluteTime.now(), Duration.fromSpec({ years: 1})); - const mailboxConf: MailboxConfiguration = { + const mailboxConf: MailboxConfiguration = { mailboxBaseUrl: mailboxBaseUrl, privateKey: privKey, privateEncryptionKey: hpkeKey, @@ -260,34 +248,39 @@ export async function refreshMailbox( const res = await mailboxClient.getMessages({hMailbox: hAddress}); switch (res.case) { case "ok": - const hpkeSk = decodeCrock(mailboxConf.privateEncryptionKey); + const hpkeSk: Uint8Array = decodeCrock(mailboxConf.privateEncryptionKey); if (res.body) { const now = TalerProtocolTimestamp.now(); - if ((res.body.length % message_size) !== 0) { - throw Error("mailbox messages response not a multiple of message size!"); + if ((res.body.byteLength % message_size) !== 0) { + throw Error(`mailbox messages response not a multiple of message size! (${res.body.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 = res.body.length / message_size; + const numMessages = res.body.byteLength / message_size; const records: MailboxMessageRecord[] = []; for (let i = 0; i < numMessages; i++) { const offset = i * message_size; - const msg = res.body.slice(offset, - offset + message_size); + const msg: Uint8Array = res.body.slice(offset, + offset + message_size); + const header = new Uint8Array(msg.slice(0, 4)); + const ct = new Uint8Array(msg.slice(4)); const uri = hpkeOpenOneshot(hpkeSk, stringToBytes("mailbox-message"), - msg.slice(0, 4), // 4 byte header. - msg.slice(4)); + header, // AAD + ct); if (!uri) { + logger.warn(`unable to decrypt message number ${i}`); continue; // FIXME log } - records.push({ + const newMessage = { originMailboxBaseUrl: mailboxConf.mailboxBaseUrl, talerUri: new TextDecoder().decode(uri), downloadedAt: now, - }); + }; + records.push(newMessage); + await addMailboxMessage(wex, {message: newMessage}); } return records; } @@ -321,19 +314,22 @@ export async function sendTalerUriMessage( throw Error("unable to get mailbox keys"); } const headerBuf = new ArrayBuffer(4); - const paddingLength = message_size - req.talerUri.length - headerBuf.byteLength; + // Padding must not include HPKE tag and encapsulation overhead + // FIXME CRYPTO-AGILITY size of tag and encapsulation depends on used algos + // DHKEM X25519: Encapsuation 32 bytes, Poly1305 tag 16 bytes + const paddingLength = message_size - 4 - req.talerUri.length - 16 - 32; const v = new DataView(headerBuf); - v.setUint16(0, 0); // FIXME message type, derive number from URI type! + v.setUint32(0, 0); // FIXME message type, derive number from URI type! const header = new Uint8Array(headerBuf); const padding = new Uint8Array(paddingLength).fill(0); - const msg = new Uint8Array([...header, ...stringToBytes(req.talerUri), ...padding]); + const msg = new Uint8Array([...stringToBytes(req.talerUri), ...padding]); const encryptedMessage = hpkeSealOneshot(decodeCrock(keys.encryptionKey), stringToBytes("mailbox-message"), header, - msg.slice(4)); + msg); const resSend = await mailboxClient.sendMessage({ h_address: req.contact.mailboxAddress, - body: encryptedMessage, + body: new Uint8Array([...header, ...encryptedMessage]), }); switch (resSend.case) { case "ok": diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -192,6 +192,7 @@ import { EddsaPrivateKey, MailboxConfiguration, SendTalerUriMailboxMessageRequest, + MailboxMessageRecord, } from "@gnu-taler/taler-util"; import { AddBackupProviderRequest, @@ -256,6 +257,7 @@ export enum WalletApiOperation { AddMailboxMessage = "addMailboxMessage", DeleteMailboxMessage = "deleteMailboxMessage", SendTalerUriMailboxMessage = "sendTalerUriMailboxMessage", + RefreshMailbox = "refreshMailbox", RetryPendingNow = "retryPendingNow", AbortTransaction = "abortTransaction", FailTransaction = "failTransaction", @@ -485,6 +487,16 @@ export type GetContactsOp = { // group: Mailbox /** + * Refresh mailbox Op. + */ +export type RefreshMailboxOp = { + op: WalletApiOperation.RefreshMailbox; + request: MailboxConfiguration; + response: MailboxMessageRecord[]; +}; + + +/** * Initialize messages mailbox Op. */ export type InitializeMailboxOp = { @@ -1724,6 +1736,7 @@ export type WalletOperations = { [WalletApiOperation.AddMailboxMessage]: AddMailboxMessageOp; [WalletApiOperation.GetMailbox]: GetMailboxOp; [WalletApiOperation.InitializeMailbox]: InitializeMailboxOp; + [WalletApiOperation.RefreshMailbox]: RefreshMailboxOp; [WalletApiOperation.SendTalerUriMailboxMessage]: SendTalerUriMailboxMessageOp; }; diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts @@ -257,6 +257,7 @@ import { stringifyScopeInfo, validateIban, codecForString, + codecForMailboxConfiguration, } from "@gnu-taler/taler-util"; import { readSuccessResponseJsonOrThrow, @@ -325,7 +326,8 @@ import { deleteMailboxMessage, initMailbox, sendTalerUriMessage, - getMailbox + getMailbox, + refreshMailbox } from "./mailbox.js"; import { ReadyExchangeSummary, @@ -1955,6 +1957,10 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = { codec: codecForEmptyObject(), handler: listMailboxMessages, }, + [WalletApiOperation.RefreshMailbox]: { + codec: codecForMailboxConfiguration(), + handler: refreshMailbox, + }, [WalletApiOperation.TestingGetDbStats]: { codec: codecForEmptyObject(), handler: async (wex) => { diff --git a/packages/taler-wallet-webextension/src/wallet/Mailbox.tsx b/packages/taler-wallet-webextension/src/wallet/Mailbox.tsx @@ -25,6 +25,10 @@ import { TalerMailboxInstanceHttpClient, sha512, MailboxMessageRecord, + TranslatedString, + succeedOrThrow, + TalerProtocolTimestamp, + AbsoluteTime } from "@gnu-taler/taler-util"; import { SafeHandler } from "../mui/handlers.js"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; @@ -42,6 +46,7 @@ import { import { alertFromError, useAlertContext } from "../context/alert.js"; import { useBackendContext } from "../context/backend.js"; import { useAsyncAsHook } from "../hooks/useAsyncAsHook.js"; +import { Alert } from "../mui/Alert.js"; import { Button } from "../mui/Button.js"; import { Grid } from "../mui/Grid.js"; import { Paper } from "../mui/Paper.js"; @@ -59,6 +64,7 @@ export function MailboxPage({ }: Props): VNode { const { i18n } = useTranslationContext(); const api = useBackendContext(); + const [error, setError] = useState<TranslatedString | undefined>(); const [search, setSearch] = useState<string>(); // FIXME get from settings; const mailboxBaseUrl = "https://mailbox.gnunet.org"; @@ -110,24 +116,30 @@ export function MailboxPage({ } const onInitializeMailbox = async () => { - await api.wallet.call(WalletApiOperation.InitializeMailbox, mailboxBaseUrl).then(); - state?.retry(); + try { + await api.wallet.call(WalletApiOperation.InitializeMailbox, mailboxBaseUrl); + state?.retry(); + } catch (err) { + setError(i18n.str`Unexpected error when trying to initialize mailbox: ${err}`); + } }; const onDeleteMessage = async (m: MailboxMessageRecord) => { - api.wallet.call(WalletApiOperation.DeleteMailboxMessage, { message: m }).then(); - }; - const onFetchMessages = async (url: string) => { - const urlParts = url.split("/"); - const h_address = urlParts[urlParts.length - 1]; - const httpClient = new BrowserFetchHttpLib(); - const mailboxClient = new TalerMailboxInstanceHttpClient(mailboxBaseUrl, httpClient); - const messagesResp = await mailboxClient.getMessages({hMailbox: h_address}); - console.log (messagesResp.type); - if (messagesResp.type != "ok") { - return; + try { + await api.wallet.call(WalletApiOperation.DeleteMailboxMessage, { message: m }); + } catch (e) { + setError(i18n.str`Unexpected error when trying to delete message: ${e}`); } - state?.retry(); }; + const onFetchMessages = async (mailbox?: MailboxConfiguration) => { + try { + if (!mailbox) { + return; + } + await api.wallet.call(WalletApiOperation.RefreshMailbox, mailbox); + } catch (err) { + setError(i18n.str`Unexpected error when trying to fetch messages: ${err}`); + } + }; return ( <MessagesView search={{ @@ -136,8 +148,10 @@ export function MailboxPage({ setSearch(d); }), }} + error={error} messages={state.response.messages} mailboxUrl={state.response.mailboxUrl} + mailbox={state.response.mailbox} onFetchMessages={onFetchMessages} onDeleteMessage={onDeleteMessage} onInitializeMailbox={onInitializeMailbox} @@ -153,12 +167,18 @@ interface MessageProps { function MailboxMessageLayout(props: MessageProps): VNode { const { i18n } = useTranslationContext(); + function toDateString(t: TalerProtocolTimestamp) : string { + if (t.t_s === "never") { + return t.t_s; + } + return new Date(t.t_s * 1000).toLocaleString(); + } return ( <Paper style={{ padding: 8 }}> <p> - <span>Message from {props.message.downloadedAt}</span> - <SmallText style={{ marginTop: 5 }}> - <i18n.Translate>URI:</i18n.Translate>: {props.message.talerUri} + <span><i18n.Translate>Message from {toDateString(props.message.downloadedAt)}</i18n.Translate></span> + <SmallText style={{ marginTop: 5 }}> + <i18n.Translate>URI</i18n.Translate>: {props.message.talerUri} </SmallText> </p> <Button variant="contained" onClick={() => { return props.onDeleteMessage(props.message)}} color="error"> @@ -171,16 +191,20 @@ function MailboxMessageLayout(props: MessageProps): VNode { export function MessagesView({ search, + error, messages, mailboxUrl, + mailbox, onFetchMessages, onDeleteMessage, onInitializeMailbox, }: { search: TextFieldHandler; + error: TranslatedString | undefined; messages: MailboxMessageRecord[]; mailboxUrl: string | undefined; - onFetchMessages: (url: string) => Promise<void>; + mailbox: MailboxConfiguration | undefined; + onFetchMessages: (mb?: MailboxConfiguration) => Promise<void>; onDeleteMessage: (m: MailboxMessageRecord) => Promise<void>; onInitializeMailbox: () => Promise<void>; }): VNode { @@ -193,6 +217,7 @@ export function MessagesView({ return ( <Fragment> <section> + <p>{error && <Alert severity="error">{error}</Alert>}</p> {mailboxUrl ? ( <Centered style={{ margin: 10 }}> <p> @@ -205,7 +230,7 @@ export function MessagesView({ </Button> </SmallText> </p> - <Button variant="contained" color="error" onClick={() => { return onFetchMessages(mailboxUrl) }}> + <Button variant="contained" onClick={() => { return onFetchMessages(mailbox) }}> <i18n.Translate>Fetch messages</i18n.Translate> </Button> </Centered> @@ -218,7 +243,7 @@ export function MessagesView({ </LightText> </SmallText> </p> - <Button variant="contained" color="error" onClick={() => { return onInitializeMailbox() }}> + <Button variant="contained" onClick={() => { return onInitializeMailbox() }}> <i18n.Translate>Initialize mailbox</i18n.Translate> </Button> </Centered>