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:
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>