taler-typescript-core

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

commit cb54440ec7e53d9e6aef489b053ea497cbfed092
parent 1225aa0deda9ea5a00e45fe129ff62d82674ae5a
Author: Martin Schanzenbach <schanzen@gnunet.org>
Date:   Wed,  5 Nov 2025 23:06:35 +0100

push wip

Diffstat:
Mpackages/taler-util/src/http-client/mailbox.ts | 39+++++++++++++++++++++++++++++++++++----
Mpackages/taler-util/src/types-taler-mailbox.ts | 32+++++++++++++++++++++++++++-----
Mpackages/taler-util/src/types-taler-wallet.ts | 4+++-
Mpackages/taler-wallet-core/src/mailbox.ts | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mpackages/taler-wallet-core/src/wallet-api-types.ts | 13++++++++++++-
Mpackages/taler-wallet-core/src/wallet.ts | 9+++++++--
Mpackages/taler-wallet-webextension/src/wallet/Mailbox.tsx | 40++++++++++++++++++++++++++++++++++------
7 files changed, 211 insertions(+), 32 deletions(-)

diff --git a/packages/taler-util/src/http-client/mailbox.ts b/packages/taler-util/src/http-client/mailbox.ts @@ -33,9 +33,14 @@ import { opKnownHttpFailure, opUnknownHttpFailure, TalerMailboxConfigResponse, - MailboxMessageKeysResponse, - codecForTalerMailboxMessageKeysResponse, + MailboxMessageKeys, + codecForTalerMailboxMessageKeys, opSuccessFromHttp, + EmptyObject, + MailboxMessageKeysUpdateRequest, + encodeCrock, + sha512, + decodeCrock, } from "@gnu-taler/taler-util"; import { HttpRequestLibrary, @@ -218,7 +223,7 @@ export class TalerMailboxInstanceHttpClient { * https://docs.taler.net/core/api-mailbox.html#key-directory */ async getKeys(hMailbox: string) : Promise< - | OperationOk<MailboxMessageKeysResponse> + | OperationOk<MailboxMessageKeys> | OperationFail<HttpStatusCode.NotFound> | OperationFail<HttpStatusCode.TooManyRequests> >{ @@ -232,7 +237,7 @@ export class TalerMailboxInstanceHttpClient { switch (resp.status) { case HttpStatusCode.Ok: { return opSuccessFromHttp(resp, - codecForTalerMailboxMessageKeysResponse()); + codecForTalerMailboxMessageKeys()); } case HttpStatusCode.TooManyRequests: { return opKnownAlternativeHttpFailure(resp, resp.status, codecForTalerMailboxRateLimitedResponse()); @@ -242,4 +247,30 @@ export class TalerMailboxInstanceHttpClient { } } + /** + * https://docs.taler.net/core/api-mailbox.html#post--keys + */ + async updateKeys(req: MailboxMessageKeysUpdateRequest) : Promise< + | OperationOk<void> + | OperationFail<HttpStatusCode.Forbidden> + >{ + const url = new URL(`keys`, this.baseUrl); + + const resp = await this.httpLib.fetch(url.href, { + method: "POST", + body: req, + cancellationToken: this.cancellationToken, + }); + + switch (resp.status) { + case HttpStatusCode.NoContent: { + return opEmptySuccess(); + } + case HttpStatusCode.Forbidden: { + return opKnownHttpFailure(resp.status, resp); + } + default: + return opUnknownHttpFailure(resp); + } + } } diff --git a/packages/taler-util/src/types-taler-mailbox.ts b/packages/taler-util/src/types-taler-mailbox.ts @@ -77,18 +77,40 @@ export interface TalerMailboxConfigResponse { delivery_period: RelativeTime; } -export const codecForTalerMailboxMessageKeysResponse = - (): Codec<MailboxMessageKeysResponse> => -buildCodecForObject<MailboxMessageKeysResponse>() +export const codecForTalerMailboxMessageKeysUpdateRequest = + (): Codec<MailboxMessageKeysUpdateRequest> => +buildCodecForObject<MailboxMessageKeysUpdateRequest>() + .property("keys", codecForTalerMailboxMessageKeys()) + .property("signature", codecForString()) + .build("TalerMailboxApi.MailboxMessageKeysUpdateRequest"); + + +export interface MailboxMessageKeysUpdateRequest { + + // Keys to add/update for a mailbox. + keys: MailboxMessageKeys; + + // Signature by the mailbox's signing key affirming + // the update of keys, of purpuse + // TALER_SIGNATURE_WALLET_MAILBOX_UPDATE_KEYS. + // The signature is created over the SHA-512 hash + // of (encryptionKeyType||encryptionKey||expiration) + signature: EddsaSignatureString; + +} + +export const codecForTalerMailboxMessageKeys = + (): Codec<MailboxMessageKeys> => +buildCodecForObject<MailboxMessageKeys>() .property("signingKey", codecForEddsaPublicKey()) .property("signingKeyType", codecForConstString("EdDSA")) .property("encryptionKey", codecForString()) .property("encryptionKeyType", codecForConstString("X25519")) .property("expiration", codecForTimestamp) - .build("TalerMailboxApi.MailboxMessageKeysResponse"); + .build("TalerMailboxApi.MailboxMessageKeys"); -export interface MailboxMessageKeysResponse { +export interface MailboxMessageKeys { // The mailbox signing key. // Note that $H_MAILBOX == H(singingKey). diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts @@ -1375,13 +1375,15 @@ export interface MailboxConfiguration { mailboxBaseUrl: string; privateKey: EddsaPrivateKeyString; privateEncryptionKey: string; + expiration: Timestamp; } export const codecForMailboxConfiguration = (): Codec<MailboxConfiguration> => - buildCodecForObject<MailboxConfiguration>() +buildCodecForObject<MailboxConfiguration>() .property("mailboxBaseUrl", codecForString()) .property("privateEncryptionKey", codecForString()) .property("privateKey", codecForEddsaPrivateKey()) + .property("expiration", codecForTimestamp) .build("MailboxConfiguration"); diff --git a/packages/taler-wallet-core/src/mailbox.ts b/packages/taler-wallet-core/src/mailbox.ts @@ -45,12 +45,22 @@ import { 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"); @@ -124,13 +134,66 @@ export async function listMailboxMessages( } /** - * Get and optionally create mailbox key from the database. + * Update mailbox keys + * Does not actually update key material in + * mailbox configuration, only updates service + * with new key info */ -export async function initMailbox( +export async function updateMailboxKeys( + wex: WalletExecutionContext, + mailboxConf: MailboxConfiguration, +): Promise<void> { + const signingKey = eddsaGetPublic(decodeCrock(mailboxConf.privateKey)); + const encryptionKey = hpkeSecretKeyGetPublic(decodeCrock(mailboxConf.privateEncryptionKey)); + // Message header: 8 byte + 64 byte SHA512 digest to sign + // We hash ktype|key|encKtype|encKey|expiration + const messageHeader = new ArrayBuffer(8); + const expNboBuffer = new ArrayBuffer(8); + const vMsg = new DataView(messageHeader); + const vExpNbo = new DataView(expNboBuffer); + if (mailboxConf.expiration.t_s == "never") { + throw Error("mailbox can not expire, invalid") + } + vExpNbo.setBigUint64(0, BigInt(mailboxConf.expiration.t_s)); + const digestBuffer = new Uint8Array([...stringToBytes("EdDSA"), + ...signingKey, + ...stringToBytes("X25519"), + ...encryptionKey, + ...(new Uint8Array(expNboBuffer))]); + vMsg.setUint32(0, vMsg.byteLength); + // FIXME: Where is gana? + vMsg.setUint32(4, 1224); + const msgToSign = new Uint8Array([...(new Uint8Array(messageHeader)), ...sha512(digestBuffer)]); + // FIXME sign + const signature = eddsaSign(msgToSign, signingKey) + const keys: MailboxMessageKeys = { + signingKey: encodeCrock(signingKey), + signingKeyType: "EdDSA", // FIXME only supported key type ATM, + encryptionKey: encodeCrock(encryptionKey), + encryptionKeyType: "X25519", // FIXME only supported encryption key type ATM + expiration: mailboxConf.expiration, + }; + const req: MailboxMessageKeysUpdateRequest = { + keys: keys, + signature: encodeCrock(signature), + }; + 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"); + } + return; +} + +/** + * Get mailbox from the database. + */ +export async function getMailbox( wex: WalletExecutionContext, mailboxBaseUrl: string, -): Promise<MailboxConfiguration> { - const res = await wex.db.runReadOnlyTx( +): Promise<MailboxConfiguration | undefined> { + return await wex.db.runReadOnlyTx( { storeNames: [ "mailboxConfigurations", @@ -140,17 +203,31 @@ export async function initMailbox( return await tx.mailboxConfigurations.get(mailboxBaseUrl); }, ); - if (res) { - return res; - } +} + + +/** + * Initialize mailbox. + */ +export async function initMailbox( + wex: WalletExecutionContext, + mailboxBaseUrl: string, +): Promise<MailboxConfiguration> { const keys = createEddsaKeyPair(); const hpkeKey = encodeCrock(hpkeCreateSecretKey()); const privKey = encodeCrock(keys.eddsaPriv); + const nowInAYear = AbsoluteTime.addDuration(AbsoluteTime.now(), Duration.fromSpec({ + years: 1})); const mailboxConf: MailboxConfiguration = { mailboxBaseUrl: mailboxBaseUrl, privateKey: privKey, privateEncryptionKey: hpkeKey, + expiration: AbsoluteTime.toProtocolTimestamp(nowInAYear) }; + logger.error(mailboxBaseUrl); + logger.error("before update keys"); + await updateMailboxKeys(wex, mailboxConf); + logger.error("after update keys"); await wex.db.runReadWriteTx( { storeNames: ["mailboxConfigurations"], @@ -181,18 +258,21 @@ export async function refreshMailbox( message_size = resConf.body.message_body_bytes; break; default: - return []; + throw Error("unable to get mailbox service config"); } const res = await mailboxClient.getMessages({hMailbox: hAddress}); switch (res.case) { case "ok": - // FIXME this only parses one message const hpkeSk = decodeCrock(mailboxConf.privateEncryptionKey); if (res.body) { const now = TalerProtocolTimestamp.now(); if ((res.body.length % message_size) !== 0) { - return []; //FIXME error + throw Error("mailbox messages response not a multiple of message size!"); } + // 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 records: MailboxMessageRecord[] = []; for (let i = 0; i < numMessages; i++) { @@ -214,9 +294,9 @@ export async function refreshMailbox( } return records; } - return []; + throw Error("mailbox messages response empty!"); default: - return []; + throw Error("unexpected mailbox messages response empty"); } } @@ -241,7 +321,7 @@ export async function sendTalerUriMessage( keys = resKeys.body; break; default: - return {}; + throw Error("unable to get mailbox keys"); } const headerBuf = new ArrayBuffer(4); const paddingLength = message_size - req.talerUri.length - headerBuf.byteLength; diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -251,6 +251,7 @@ export enum WalletApiOperation { DeleteContact = "deleteContact", GetContacts = "getContacts", GetMailbox = "getMailbox", + InitializeMailbox = "initializeMailbox", GetMailboxMessages = "getMailboxMessage", AddMailboxMessage = "addMailboxMessage", DeleteMailboxMessage = "deleteMailboxMessage", @@ -486,10 +487,19 @@ export type GetContactsOp = { /** * Initialize messages mailbox Op. */ +export type InitializeMailboxOp = { + op: WalletApiOperation.InitializeMailbox; + request: string; + response: MailboxConfiguration; +}; + +/** + * Get messages mailbox Op. + */ export type GetMailboxOp = { op: WalletApiOperation.GetMailbox; request: string; - response: MailboxConfiguration; + response: MailboxConfiguration | undefined; }; @@ -1713,6 +1723,7 @@ export type WalletOperations = { [WalletApiOperation.DeleteMailboxMessage]: DeleteMailboxMessageOp; [WalletApiOperation.AddMailboxMessage]: AddMailboxMessageOp; [WalletApiOperation.GetMailbox]: GetMailboxOp; + [WalletApiOperation.InitializeMailbox]: InitializeMailboxOp; [WalletApiOperation.SendTalerUriMailboxMessage]: SendTalerUriMailboxMessageOp; }; diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts @@ -324,7 +324,8 @@ import { addMailboxMessage, deleteMailboxMessage, initMailbox, - sendTalerUriMessage + sendTalerUriMessage, + getMailbox } from "./mailbox.js"; import { ReadyExchangeSummary, @@ -1930,10 +1931,14 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = { codec: codecForEmptyObject(), handler: listContacts, }, - [WalletApiOperation.GetMailbox]: { + [WalletApiOperation.InitializeMailbox]: { codec: codecForString(), handler: initMailbox, }, + [WalletApiOperation.GetMailbox]: { + codec: codecForString(), + handler: getMailbox, + }, [WalletApiOperation.SendTalerUriMailboxMessage]: { codec: codecForSendTalerUriMailboxMessageRequest(), handler: sendTalerUriMessage, diff --git a/packages/taler-wallet-webextension/src/wallet/Mailbox.tsx b/packages/taler-wallet-webextension/src/wallet/Mailbox.tsx @@ -61,7 +61,7 @@ export function MailboxPage({ const api = useBackendContext(); const [search, setSearch] = useState<string>(); // FIXME get from settings; - const mailboxBaseUrl = "http://localhost:11000"; + const mailboxBaseUrl = "https://mailbox.gnunet.org"; var mailboxClient: TalerMailboxInstanceHttpClient; var activeMailbox: MailboxConfiguration | undefined; const httpClient = new BrowserFetchHttpLib(); @@ -71,10 +71,13 @@ export function MailboxPage({ const b = await api.wallet.call(WalletApiOperation.GetMailboxMessages, {}); activeMailbox = await api.wallet.call(WalletApiOperation.GetMailbox, mailboxBaseUrl); + var mailboxUrl; + if (activeMailbox) { // FIXME put this into the mailbox config directly? - const privKey = decodeCrock(activeMailbox.privateKey); - const pubKey = eddsaGetPublic(privKey); - const mailboxUrl = activeMailbox.mailboxBaseUrl + '/' + encodeCrock(sha512(pubKey)); + const privKey = decodeCrock(activeMailbox.privateKey); + const pubKey = eddsaGetPublic(privKey); + mailboxUrl = activeMailbox.mailboxBaseUrl + '/' + encodeCrock(sha512(pubKey)); + } return { messages: b.messages, mailbox: activeMailbox, @@ -106,6 +109,10 @@ export function MailboxPage({ ); } + const onInitializeMailbox = async () => { + await api.wallet.call(WalletApiOperation.InitializeMailbox, mailboxBaseUrl).then(); + state?.retry(); + }; const onDeleteMessage = async (m: MailboxMessageRecord) => { api.wallet.call(WalletApiOperation.DeleteMailboxMessage, { message: m }).then(); }; @@ -133,6 +140,7 @@ export function MailboxPage({ mailboxUrl={state.response.mailboxUrl} onFetchMessages={onFetchMessages} onDeleteMessage={onDeleteMessage} + onInitializeMailbox={onInitializeMailbox} /> ); } @@ -167,20 +175,25 @@ export function MessagesView({ mailboxUrl, onFetchMessages, onDeleteMessage, + onInitializeMailbox, }: { search: TextFieldHandler; messages: MailboxMessageRecord[]; - mailboxUrl: string; + mailboxUrl: string | undefined; onFetchMessages: (url: string) => Promise<void>; onDeleteMessage: (m: MailboxMessageRecord) => Promise<void>; + onInitializeMailbox: () => Promise<void>; }): VNode { const { i18n } = useTranslationContext(); async function copy(): Promise<void> { - navigator.clipboard.writeText(mailboxUrl); + if (mailboxUrl) { + navigator.clipboard.writeText(mailboxUrl); + } } return ( <Fragment> <section> + {mailboxUrl ? ( <Centered style={{ margin: 10 }}> <p> <SmallText style={{ marginTop: 5 }}> @@ -196,6 +209,21 @@ export function MessagesView({ <i18n.Translate>Fetch messages</i18n.Translate> </Button> </Centered> + ) : ( + <Centered style={{ margin: 10 }}> + <p> + <SmallText style={{ marginTop: 5 }}> + <LightText> + <i18n.Translate>Mailbox not initialized</i18n.Translate> + </LightText> + </SmallText> + </p> + <Button variant="contained" color="error" onClick={() => { return onInitializeMailbox() }}> + <i18n.Translate>Initialize mailbox</i18n.Translate> + </Button> + </Centered> + + )} {(messages.length == 0) ? ( <Centered style={{ marginTop: 20 }}> <BoldLight>