commit 272af1340c7fa49e6bcbd76bcca00a4b5f94f39a
parent da4eb33ae54c586079d8b71549f89ced347e16f7
Author: Martin Schanzenbach <schanzen@gnunet.org>
Date: Sun, 2 Nov 2025 13:41:43 +0100
Add Mailbox crypto including HPKE
Diffstat:
11 files changed, 786 insertions(+), 120 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-mailbox-basic.ts b/packages/taler-harness/src/integrationtests/test-wallet-mailbox-basic.ts
@@ -17,12 +17,12 @@
/**
* Imports.
*/
-import { MailboxMessageMoneyTransfer, MailboxMessagePaymentRequestInvoice, MailboxMessageType } from "@gnu-taler/taler-util";
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
import { GlobalTestState } from "../harness/harness.js";
import {
createSimpleTestkudosEnvironmentV3,
} from "../harness/environments.js";
+import { MailboxMessageRecord, TalerPreciseTimestamp, TalerProtocolTimestamp } from "@gnu-taler/taler-util";
/**
* Run test for basic mailbox operations.
@@ -37,21 +37,15 @@ export async function runWalletMailboxBasicTest(t: GlobalTestState) {
const mbConfAgain = await walletClient.call(WalletApiOperation.GetMailbox, mailboxBaseUrl);
t.assertDeepEqual(mbConf, mbConfAgain);
- const messageBob: MailboxMessageMoneyTransfer = {
+ const messageBob: MailboxMessageRecord = {
originMailboxBaseUrl: mailboxBaseUrl,
- type: MailboxMessageType.MoneyTransfer,
- id: "0",
- senderHint: "bobby",
- payPushUri: "taler://pay-push/XQXA",
- bodyRaw: "ABCD",
+ talerUri: "taler://pay-push/XQXB",
+ downloadedAt: TalerProtocolTimestamp.now(),
};
- const messageAlice: MailboxMessagePaymentRequestInvoice = {
+ const messageAlice: MailboxMessageRecord = {
originMailboxBaseUrl: mailboxBaseUrl,
- type: MailboxMessageType.PaymentRequestInvoice,
- id: "1",
- senderHint: "jack",
- payPullUri: "taler://pay-pull/XQXA",
- bodyRaw: "EFGH",
+ talerUri: "taler://pay-pull/XQXA",
+ downloadedAt: TalerProtocolTimestamp.now(),
};
await walletClient.call(WalletApiOperation.AddMailboxMessage, {
message: messageBob,
diff --git a/packages/taler-util/src/http-client/mailbox.ts b/packages/taler-util/src/http-client/mailbox.ts
@@ -31,7 +31,8 @@ import {
opFixedSuccess,
opKnownAlternativeHttpFailure,
opKnownHttpFailure,
- opUnknownHttpFailure
+ opUnknownHttpFailure,
+ TalerMailboxConfigResponse,
} from "@gnu-taler/taler-util";
import {
HttpRequestLibrary,
@@ -80,7 +81,11 @@ export class TalerMailboxInstanceHttpClient {
/**
* https://docs.taler.net/core/api-mailbox.html#get--config
*/
- async getConfig() {
+ async getConfig() :
+ Promise<
+ | OperationOk<TalerMailboxApi.TalerMailboxConfigResponse>
+ | OperationFail<HttpStatusCode.NotFound>
+ >{
const url = new URL(`config`, this.baseUrl);
const resp = await this.httpLib.fetch(url.href, {
method: "GET",
@@ -160,9 +165,7 @@ export class TalerMailboxInstanceHttpClient {
switch (resp.status) {
case HttpStatusCode.Ok: {
- // FIXME how do we decode the byte array?
- const buffer = await resp.bytes();
- const uintar = new Uint8Array(buffer);
+ const uintar = await resp.bytes() as Uint8Array;
return opFixedSuccess(uintar);
}
case HttpStatusCode.NoContent: {
diff --git a/packages/taler-util/src/nacl-fast.ts b/packages/taler-util/src/nacl-fast.ts
@@ -68,7 +68,7 @@ function vn(
return (1 & ((d - 1) >>> 8)) - 1;
}
-function crypto_verify_16(
+export function crypto_verify_16(
x: Uint8Array,
xi: number,
y: Uint8Array,
@@ -687,7 +687,7 @@ function crypto_stream_xor(
* https://github.com/floodyberry/poly1305-donna
*/
-class poly1305 {
+export class poly1305 {
buffer = new Uint8Array(16);
r = new Uint16Array(10);
h = new Uint16Array(10);
diff --git a/packages/taler-util/src/notifications.ts b/packages/taler-util/src/notifications.ts
@@ -27,7 +27,7 @@ import { TransactionState } from "./types-taler-wallet-transactions.js";
import {
ContactEntry,
ExchangeEntryState,
- MailboxMessage,
+ MailboxMessageRecord,
TalerErrorDetail,
TransactionIdStr,
} from "./types-taler-wallet.js";
@@ -155,7 +155,7 @@ export interface MailboxMessageAddedNotification {
/**
* The message that was added
*/
- message: MailboxMessage;
+ message: MailboxMessageRecord;
}
/**
@@ -167,7 +167,7 @@ export interface MailboxMessageDeletedNotification {
/**
* The message that was deleted
*/
- message: MailboxMessage;
+ message: MailboxMessageRecord;
}
/**
diff --git a/packages/taler-util/src/taler-crypto.test.ts b/packages/taler-util/src/taler-crypto.test.ts
@@ -38,6 +38,10 @@ import {
bigintToNaclArr,
bigintFromNaclArr,
kdf,
+ chacha20_ietf_xor,
+ chacha20poly1305_ietf_encrypt,
+ chacha20poly1305_ietf_decrypt,
+ chacha20_ietf,
} from "./taler-crypto.js";
import { sha512 } from "./kdf.js";
import * as nacl from "./nacl-fast.js";
@@ -438,3 +442,111 @@ test("edx test vector", async (t) => {
);
t.deepEqual(priv2Prime, decodeCrock(tv.priv2_edx));
});
+
+test("chacha20 test vector", async (t) => {
+ const key = new Uint8Array([
+ 0x00, 0x01, 0x02, 0x03, 0x04,
+ 0x05, 0x06, 0x07, 0x08, 0x09,
+ 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
+ 0x0f, 0x10, 0x11, 0x12, 0x13,
+ 0x14, 0x15, 0x16, 0x17, 0x18,
+ 0x19, 0x1a, 0x1b, 0x1c, 0x1d,
+ 0x1e, 0x1f
+ ]);
+
+ const nonce = new Uint8Array([
+ 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x4a,
+ 0x00, 0x00, 0x00, 0x00
+ ]);
+
+ const plaintextBytes = new Uint8Array([
+ 0x4c, 0x61, 0x64, 0x69, 0x65, 0x73, 0x20, 0x61, 0x6e, 0x64, 0x20, 0x47, 0x65, 0x6e, 0x74, 0x6c,
+ 0x65, 0x6d, 0x65, 0x6e, 0x20, 0x6f, 0x66, 0x20, 0x74, 0x68, 0x65, 0x20, 0x63, 0x6c, 0x61, 0x73,
+ 0x73, 0x20, 0x6f, 0x66, 0x20, 0x27, 0x39, 0x39, 0x3a, 0x20, 0x49, 0x66, 0x20, 0x49, 0x20, 0x63,
+ 0x6f, 0x75, 0x6c, 0x64, 0x20, 0x6f, 0x66, 0x66, 0x65, 0x72, 0x20, 0x79, 0x6f, 0x75, 0x20, 0x6f,
+ 0x6e, 0x6c, 0x79, 0x20, 0x6f, 0x6e, 0x65, 0x20, 0x74, 0x69, 0x70, 0x20, 0x66, 0x6f, 0x72, 0x20,
+ 0x74, 0x68, 0x65, 0x20, 0x66, 0x75, 0x74, 0x75, 0x72, 0x65, 0x2c, 0x20, 0x73, 0x75, 0x6e, 0x73,
+ 0x63, 0x72, 0x65, 0x65, 0x6e, 0x20, 0x77, 0x6f, 0x75, 0x6c, 0x64, 0x20, 0x62, 0x65, 0x20, 0x69,
+ 0x74, 0x2e
+ ]);
+
+ // encrypt
+ const ciphertext = chacha20_ietf_xor(key, nonce, plaintextBytes, 1);
+
+ // decrypt
+ const plaintext = chacha20_ietf_xor(key, nonce, ciphertext, 1);
+ const decoder = new TextDecoder();
+ const plaintextStr = decoder.decode(plaintextBytes);
+ t.deepEqual(plaintextStr, 'Ladies and Gentlemen of the class of \'99: If I could offer you only one tip for the future, sunscreen would be it.');
+});
+
+
+test("chacha20poly1305 test vector", async (t) => {
+ const key = new Uint8Array([
+ 0x80 , 0x81 , 0x82 , 0x83 , 0x84 , 0x85 , 0x86 , 0x87 , 0x88 , 0x89 , 0x8a , 0x8b , 0x8c , 0x8d , 0x8e , 0x8f,
+ 0x90 , 0x91 , 0x92 , 0x93 , 0x94 , 0x95 , 0x96 , 0x97 , 0x98 , 0x99 , 0x9a , 0x9b , 0x9c , 0x9d , 0x9e , 0x9f
+ ]);
+
+ const nonce = new Uint8Array([
+ 0x07, 0x00, 0x00, 0x00,
+ 0x40, 0x41, 0x42, 0x43,
+ 0x44, 0x45, 0x46, 0x47,
+ ]);
+
+ const aad = new Uint8Array([
+ 0x50, 0x51, 0x52, 0x53,
+ 0xc0, 0xc1, 0xc2, 0xc3,
+ 0xc4, 0xc5, 0xc6, 0xc7,
+ ]);
+
+ const plaintextBytes = new Uint8Array([
+ 0x4c, 0x61, 0x64, 0x69, 0x65, 0x73, 0x20, 0x61, 0x6e, 0x64, 0x20, 0x47, 0x65, 0x6e, 0x74, 0x6c,
+ 0x65, 0x6d, 0x65, 0x6e, 0x20, 0x6f, 0x66, 0x20, 0x74, 0x68, 0x65, 0x20, 0x63, 0x6c, 0x61, 0x73,
+ 0x73, 0x20, 0x6f, 0x66, 0x20, 0x27, 0x39, 0x39, 0x3a, 0x20, 0x49, 0x66, 0x20, 0x49, 0x20, 0x63,
+ 0x6f, 0x75, 0x6c, 0x64, 0x20, 0x6f, 0x66, 0x66, 0x65, 0x72, 0x20, 0x79, 0x6f, 0x75, 0x20, 0x6f,
+ 0x6e, 0x6c, 0x79, 0x20, 0x6f, 0x6e, 0x65, 0x20, 0x74, 0x69, 0x70, 0x20, 0x66, 0x6f, 0x72, 0x20,
+ 0x74, 0x68, 0x65, 0x20, 0x66, 0x75, 0x74, 0x75, 0x72, 0x65, 0x2c, 0x20, 0x73, 0x75, 0x6e, 0x73,
+ 0x63, 0x72, 0x65, 0x65, 0x6e, 0x20, 0x77, 0x6f, 0x75, 0x6c, 0x64, 0x20, 0x62, 0x65, 0x20, 0x69,
+ 0x74, 0x2e
+ ]);
+ const polyKey = new Uint8Array([
+ 0x7b, 0xac, 0x2b, 0x25,
+ 0x2d, 0xb4, 0x47, 0xaf,
+ 0x09, 0xb6, 0x7a, 0x55,
+ 0xa4, 0xe9, 0x55, 0x84,
+ 0x0a, 0xe1, 0xd6, 0x73,
+ 0x10, 0x75, 0xd9, 0xeb,
+ 0x2a, 0x93, 0x75, 0x78,
+ 0x3e, 0xd5, 0x53, 0xff,
+ ]);
+ const polyOtk = new Uint8Array([
+ 0x25, 0x2b, 0xac, 0x7b,
+ 0xaf, 0x47, 0xb4, 0x2d,
+ 0x55, 0x7a, 0xb6, 0x09,
+ 0x84, 0x55, 0xe9, 0xa4,
+ 0x73, 0xd6, 0xe1, 0x0a,
+ 0xeb, 0xd9, 0x75, 0x10,
+ 0x78, 0x75, 0x93, 0x25,
+ 0xff, 0x53, 0xd5, 0x3e,
+ 0xde, 0xcc, 0x7e, 0xa2,
+ 0xb4, 0x4d, 0xdb, 0xad,
+ 0xe4, 0x9c, 0x17, 0xd1,
+ 0xd8, 0x43, 0x0b, 0xc9,
+ 0x8c, 0x94, 0xb7, 0xbc,
+ 0x8b, 0x7d, 0x4b, 0x4b,
+ 0x39, 0x27, 0xf6, 0x7d,
+ 0x16, 0x69, 0xa4, 0x32,
+ ]);
+ const block0 = chacha20_ietf(64, key, nonce);
+ t.deepEqual(block0.slice(0, 32), polyKey);
+ // encrypt
+ const ciphertext = chacha20poly1305_ietf_encrypt(plaintextBytes, aad, nonce, key);
+
+ // decrypt
+ const plaintext = chacha20poly1305_ietf_decrypt(ciphertext, aad, nonce, key);
+ t.false(plaintext === undefined);
+ const decoder = new TextDecoder();
+ const plaintextStr = decoder.decode(plaintext);
+ t.deepEqual(plaintextStr, 'Ladies and Gentlemen of the class of \'99: If I could offer you only one tip for the future, sunscreen would be it.');
+});
diff --git a/packages/taler-util/src/taler-crypto.ts b/packages/taler-util/src/taler-crypto.ts
@@ -31,7 +31,7 @@ import { Logger } from "./logging.js";
import * as nacl from "./nacl-fast.js";
import { secretbox } from "./nacl-fast.js";
import { TalerProtocolDuration, TalerProtocolTimestamp } from "./time.js";
-import { CoinPublicKeyString, HashCodeString } from "./types-taler-common.js";
+import { CoinPublicKeyString, EcdhePublicKey, HashCodeString } from "./types-taler-common.js";
import {
CoinEnvelope,
DenomKeyType,
@@ -39,6 +39,7 @@ import {
} from "./types-taler-exchange.js";
import { TokenEnvelope, TokenIssuePublicKey } from "./types-taler-merchant.js";
import { PayWalletData } from "./types-taler-wallet.js";
+import { arrayBuffer } from "node:stream/consumers";
const isEddsaPubP: unique symbol = Symbol("isEddsaPubP");
type FlavorEddsaPubP = {
@@ -1756,3 +1757,510 @@ export function toHexString(byteArray: Uint8Array) {
"",
);
}
+
+export type HpkePublicKey = Uint8Array
+
+export type HpkeEncapsulation = Uint8Array
+
+export function hkdf_extract_sha256(
+ ikm: Uint8Array,
+ salt?: Uint8Array,
+): Uint8Array {
+ salt = salt ?? new Uint8Array(64);
+ // extract
+ return hmacSha256(salt, ikm);
+
+}
+
+export function hkdf_expand_sha256(
+ outputLength: number,
+ prk: Uint8Array,
+ info?: Uint8Array,
+): Uint8Array {
+
+ info = info ?? new Uint8Array(0);
+
+ // expand
+ const N = Math.ceil(outputLength / 32);
+ const output = new Uint8Array(N * 32);
+ for (let i = 0; i < N; i++) {
+ let buf;
+ if (i == 0) {
+ buf = new Uint8Array(info.byteLength + 1);
+ buf.set(info, 0);
+ } else {
+ buf = new Uint8Array(info.byteLength + 1 + 32);
+ for (let j = 0; j < 32; j++) {
+ buf[j] = output[(i - 1) * 32 + j];
+ }
+ buf.set(info, 32);
+ }
+ buf[buf.length - 1] = i + 1;
+ const chunk = hmacSha256(prk, buf);
+ output.set(chunk, i * 32);
+ }
+
+ return output.slice(0, outputLength);
+}
+
+
+export function labeledExpand(
+ ctx: string,
+ prk: Uint8Array,
+ label: string,
+ info: Uint8Array,
+ suiteId: Uint8Array,
+ outLength: number) : Uint8Array {
+ const outLenBytes = new ArrayBuffer(2);
+ const out = new DataView(outLenBytes);
+ out.setUint16(0, outLength);
+ const labelBytes = stringToBytes(label);
+ const ctxBytes = stringToBytes(ctx);
+ const labeledInfo = new Uint8Array([...(new Uint8Array(outLenBytes)), ...ctxBytes, ...suiteId, ...labelBytes, ...info]);
+ return hkdf_expand_sha256(outLength, prk, labeledInfo);
+}
+
+export function labeledExtract(
+ ctx: string,
+ label: Uint8Array,
+ ikm: Uint8Array,
+ suiteId: Uint8Array,
+ salt?: Uint8Array): Uint8Array {
+ const ctxBytes = stringToBytes(ctx);
+ const labeledIkm = new Uint8Array([...ctxBytes, ...suiteId, ...label, ...ikm]);
+ return hkdf_extract_sha256(labeledIkm, salt);
+
+}
+
+export function ecdh_x25519(
+ ecdhPriv: Uint8Array,
+ ecdhPub: Uint8Array,
+): Uint8Array {
+ var checkbyte = 0;
+ const res = nacl.scalarMult(ecdhPriv, ecdhPub);
+ for (let i = 0; i < res.length; i++) {
+ checkbyte = res[i] | checkbyte;
+ }
+ if (checkbyte == 0) {
+ throw Error("x25519 failed");
+ }
+ return res;
+}
+
+export function hpkeKemEncapsNorand(
+ pkR: Uint8Array,
+ skE: Uint8Array): [HpkeEncapsulation, Uint8Array] {
+ const enc = ecdhGetPublic(skE);
+ const kem_context = new Uint8Array([...enc, ...pkR]);
+ const dh = ecdh_x25519 (skE, pkR);
+ const suiteId = new Uint8Array([0x4B,0x45,0x4D,0x00,0x20]);
+ const prk = labeledExtract("HPKE-v1",
+ stringToBytes("eae_prk"),
+ dh,
+ suiteId);
+ const ss = labeledExpand("HPKE-v1",
+ prk,
+ "shared_secret",
+ kem_context,
+ suiteId,
+ 32);
+ return [enc, ss];
+}
+
+export function hpkeKemDecaps(
+ skR: Uint8Array,
+ enc: Uint8Array): Uint8Array {
+ const pkR = ecdhGetPublic(skR);
+ const dh = ecdh_x25519(skR, enc);
+ const kem_context = new Uint8Array([...enc, ...pkR]);
+ const suiteId = new Uint8Array([0x4B,0x45,0x4D,0x00,0x20]);
+ const prk = labeledExtract("HPKE-v1",
+ stringToBytes("eae_prk"),
+ dh,
+ suiteId);
+ const ss = labeledExpand("HPKE-v1",
+ prk,
+ "shared_secret",
+ kem_context,
+ suiteId,
+ 32);
+ return ss;
+
+}
+
+enum HpkeRole {
+ Sender,
+ Receiver,
+}
+
+enum HpkeMode {
+ Base = 0x00,
+ PSK = 0x01,
+}
+
+export interface HpkeContext {
+ key: Uint8Array,
+ nonce: Uint8Array,
+ seq: number,
+ role: HpkeRole,
+}
+
+export function hpkeKeySchedule(
+ role: HpkeRole,
+ mode: HpkeMode,
+ sharedSecret: Uint8Array,
+ info: Uint8Array,
+ psk?: Uint8Array,
+ pskId?: Uint8Array) : HpkeContext {
+ const suiteId = new ArrayBuffer(4 + 3 * 2);
+ const v = new DataView(suiteId);
+ v.setUint8(0, 0x48); // "H"
+ v.setUint8(1, 0x50); // "P"
+ v.setUint8(2, 0x4B); // "K"
+ v.setUint8(3, 0x45); // "E"
+ v.setUint16(4, 32); // kemID (DHKEM(X25519, HKDF-256))
+ v.setUint16(6, 1); // KDF ID (HKDF-SHA256)
+ v.setUint16(8, 3); // AEAD ID (ChaChaPoly1305)
+
+ if (mode == HpkeMode.PSK) {
+ if (!psk) {
+ throw Error("Mode is PSK, but PSK not provided");
+ }
+ if (!psk) {
+ throw Error("Mode is PSK, but PSKID not provided");
+ }
+ } else if (mode == HpkeMode.Base) {
+ if (psk) {
+ throw Error("PSK provided, but mode is Base");
+ }
+ if (pskId) {
+ throw Error("PSKID provided, but mode is Base");
+ }
+ }
+ const suiteIdBytes = new Uint8Array(suiteId);
+ const pskIdHash = labeledExtract("HPKE-v1",
+ stringToBytes("psk_id_hash"),
+ new Uint8Array([]),
+ suiteIdBytes);
+ const infoHash = labeledExtract("HPKE-v1",
+ stringToBytes("info_hash"),
+ info,
+ suiteIdBytes);
+ const keyScheduleCtx = new Uint8Array([mode, ...pskIdHash, ...infoHash]);
+ const secret = labeledExtract("HPKE-v1",
+ stringToBytes("secret"),
+ psk || new Uint8Array([]),
+ suiteIdBytes,
+ sharedSecret);
+ const ctxKey = labeledExpand("HPKE-v1",
+ secret,
+ "key",
+ keyScheduleCtx,
+ suiteIdBytes,
+ 32); // key 32 bytes / 256 bit
+ const ctxNonce = labeledExpand("HPKE-v1",
+ secret,
+ "base_nonce",
+ keyScheduleCtx,
+ suiteIdBytes,
+ 12); // nonce 12 bytes
+ // We do not support secret export hence no secret export labeledExtract
+
+ return {key: ctxKey, nonce: ctxNonce, role: role, seq: 0};
+}
+
+
+export function hpkeSenderSetup(
+ pkR: HpkePublicKey,
+ info: Uint8Array) : [HpkeEncapsulation, HpkeContext] {
+ const keypair = createEcdheKeyPair();
+ const [enc, sharedSecret] = hpkeKemEncapsNorand(pkR, keypair.ecdhePriv);
+ const ctx = hpkeKeySchedule(HpkeRole.Sender,
+ HpkeMode.Base,
+ sharedSecret,
+ info);
+ return [enc, ctx];
+}
+
+function hpkeComputeNonce(
+ ctx: HpkeContext) : Uint8Array {
+ const nonce = new ArrayBuffer(12);
+ const v = new DataView(nonce);
+ const offset = 12 - 8; // nonce length - sequence counter of 64 bits
+ for (let i = 0; i < offset; i++) {
+ v.setUint8(i, ctx.nonce[i]);
+ }
+ v.setBigUint64(offset, BigInt(ctx.seq));
+ return new Uint8Array(nonce);
+}
+
+export function hpkeSealOneshot(
+ pkR: HpkePublicKey,
+ info: Uint8Array,
+ aad: Uint8Array,
+ plaintext: Uint8Array): Uint8Array {
+ const [enc, ctx] = hpkeSenderSetup(pkR, info);
+ const nonce = hpkeComputeNonce(ctx);
+ const ct = chacha20poly1305_ietf_encrypt(plaintext,
+ aad,
+ nonce,
+ ctx.key);
+ ctx.seq++;
+ return new Uint8Array([...enc, ...ct]);
+}
+
+export type HpkeSecretKey = Uint8Array;
+
+export function hpkeReceiverSetup(
+ enc: Uint8Array,
+ skR: HpkeSecretKey,
+ info: Uint8Array) : HpkeContext {
+ const sharedSecret = hpkeKemDecaps(skR, enc);
+ return hpkeKeySchedule(HpkeRole.Receiver,
+ HpkeMode.Base,
+ sharedSecret,
+ info);
+}
+
+export function hpkeOpenOneshot(
+ skR: HpkeSecretKey,
+ 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,
+ aad,
+ nonce,
+ ctx.key);
+}
+
+export function hpkeSecretKeyGetPublic(
+ sk: HpkeSecretKey) : HpkePublicKey {
+ return ecdhGetPublic(sk as Uint8Array) as HpkePublicKey;
+}
+
+export function hpkeCreateSecretKey() : HpkeSecretKey {
+ const keypair = createEcdheKeyPair();
+ return keypair.ecdhePriv as HpkeSecretKey;
+}
+
+export interface ChaCha20Context {
+ input: Uint32Array,
+}
+
+function chacha20_toUint32(data: Uint8Array | number[], index: number): number {
+ return data[index++] ^ (data[index++] << 8) ^ (data[index++] << 16) ^ (data[index] << 24)
+}
+
+function chacha20_rotl(data: number, shift: number): number {
+ return ((data << shift) | (data >>> (32 - shift)))
+}
+
+export function chacha20_quarterround(
+ out: number[],
+ a: number,
+ b: number,
+ c: number,
+ d: number) {
+ out[d] = chacha20_rotl(out[d] ^ (out[a] += out[b]), 16);
+ out[b] = chacha20_rotl(out[b] ^ (out[c] += out[d]), 12);
+ out[d] = chacha20_rotl(out[d] ^ (out[a] += out[b]), 8);
+ out[b] = chacha20_rotl(out[b] ^ (out[c] += out[d]), 7);
+
+ out[a] >>>= 0;
+ out[b] >>>= 0;
+ out[c] >>>= 0;
+ out[d] >>>= 0;
+}
+
+export function chacha20_block(
+ input: number[]) : Uint8Array {
+ const out = Array<number>(64).fill(0);
+ // copy param array to x
+ const x = Array.from(input);
+ var i = 0;
+ var bytesWritten = 0;
+
+ // 10 loops × 2 rounds/loop = 20 rounds
+ for (i = 0; i < 20; i += 2) {
+ // Odd round
+ chacha20_quarterround(x, 0, 4, 8, 12)
+ chacha20_quarterround(x, 1, 5, 9, 13)
+ chacha20_quarterround(x, 2, 6, 10, 14)
+ chacha20_quarterround(x, 3, 7, 11, 15)
+
+ // Even round
+ chacha20_quarterround(x, 0, 5, 10, 15)
+ chacha20_quarterround(x, 1, 6, 11, 12)
+ chacha20_quarterround(x, 2, 7, 8, 13)
+ chacha20_quarterround(x, 3, 4, 9, 14)
+ }
+
+ for (i = 0; i < 16; i++) {
+ // out[i] = x[i] + in[i]
+ let tmp = x[i] + input[i]
+
+ // update pad
+ out[bytesWritten++] = tmp & 0xFF
+ out[bytesWritten++] = (tmp >>> 8) & 0xFF
+ out[bytesWritten++] = (tmp >>> 16) & 0xFF
+ out[bytesWritten++] = (tmp >>> 24) & 0xFF
+ }
+ return new Uint8Array([...out]);
+}
+
+export function chacha20_ietf_xor(
+ key: Uint8Array,
+ nonce: Uint8Array,
+ m: Uint8Array,
+ c?: number) : Uint8Array {
+ invariant (0 != m.length);
+ var bytesWritten = 0;
+ const out = new Uint8Array(m.length);
+ const sigma: number[] = [0x61707865, 0x3320646e, 0x79622d32, 0x6b206574];
+ const keybytes = [
+ chacha20_toUint32(key, 0),
+ chacha20_toUint32(key, 4),
+ chacha20_toUint32(key, 8),
+ chacha20_toUint32(key, 12),
+ chacha20_toUint32(key, 16),
+ chacha20_toUint32(key, 20),
+ chacha20_toUint32(key, 24),
+ chacha20_toUint32(key, 28),
+ ];
+ const noncebytes = [
+ chacha20_toUint32(nonce, 0),
+ chacha20_toUint32(nonce, 4),
+ chacha20_toUint32(nonce, 8),
+ ];
+ const param: number[] = [
+ ...sigma,
+ ...keybytes,
+ c? c: 0, // Counter, index is 12
+ ...noncebytes,
+ ];
+ for (let i = 0; i < m.length; i++) {
+ var pad;
+ if (bytesWritten === 0 || bytesWritten === 64) {
+ // generate new block //
+
+ pad = chacha20_block(param);
+ // counter increment
+ param[12]++;
+
+ // bytes counter for wrap around
+ bytesWritten = 0;
+ }
+ invariant(pad != undefined);
+ out[i] = m[i] ^ pad[bytesWritten++]
+ }
+
+ return out;
+}
+export function chacha20_ietf(
+ clen: number,
+ key: Uint8Array,
+ nonce: Uint8Array) : Uint8Array {
+ var bytesWritten = 0;
+ const m = Array<number>(clen).fill(0);
+ const out = new Uint8Array(m.length);
+ const sigma: number[] = [0x61707865, 0x3320646e, 0x79622d32, 0x6b206574];
+ const keybytes = [
+ chacha20_toUint32(key, 0),
+ chacha20_toUint32(key, 4),
+ chacha20_toUint32(key, 8),
+ chacha20_toUint32(key, 12),
+ chacha20_toUint32(key, 16),
+ chacha20_toUint32(key, 20),
+ chacha20_toUint32(key, 24),
+ chacha20_toUint32(key, 28),
+ ];
+ const noncebytes = [
+ chacha20_toUint32(nonce, 0),
+ chacha20_toUint32(nonce, 4),
+ chacha20_toUint32(nonce, 8),
+ ];
+ const param: number[] = [
+ ...sigma,
+ ...keybytes,
+ 0, // Counter, index is 12
+ ...noncebytes,
+ ];
+ for (let i = 0; i < m.length; i++) {
+ var pad;
+ if (bytesWritten === 0 || bytesWritten === 64) {
+ // generate new block //
+
+ pad = chacha20_block(param);
+ // counter increment
+ param[12]++;
+
+ // bytes counter for wrap around
+ bytesWritten = 0;
+ }
+ invariant(pad != undefined);
+ out[i] = m[i] ^ pad[bytesWritten++]
+ }
+
+ return out;
+}
+
+export function chacha20poly1305_ietf_encrypt(
+ m: Uint8Array,
+ ad: Uint8Array,
+ npub: Uint8Array,
+ k: Uint8Array) : Uint8Array {
+ invariant(k.length == 32);
+ invariant(npub.length == 12);
+ const slenBuf = new ArrayBuffer(8);
+ const slenDv = new DataView(slenBuf);
+ const pad0 = new Uint8Array(16).fill(0);
+ const block0 = chacha20_ietf(64, k, npub);
+ const tag = new Uint8Array(16);
+ const p = new nacl.poly1305(block0);
+ p.update(ad, 0, ad.length);
+ p.update(pad0, 0, (0x10 - ad.length) & 0xf);
+ const ct = chacha20_ietf_xor(k, npub, m, 1);
+ p.update(ct, 0, ct.length);
+ p.update(pad0, 0, (0x10 - m.length) & 0xf);
+ slenDv.setBigUint64(0, BigInt(ad.length), true);
+ p.update(new Uint8Array(slenBuf), 0, 8);
+ slenDv.setBigUint64(0, BigInt(ct.length), true);
+ p.update(new Uint8Array(slenBuf), 0, 8);
+ p.finish(tag, 0);
+ return new Uint8Array([...ct, ...tag]);
+}
+
+export function chacha20poly1305_ietf_decrypt(
+ ct: Uint8Array,
+ ad: Uint8Array,
+ npub: Uint8Array,
+ k: Uint8Array) : Uint8Array | undefined {
+ invariant(k.length == 32);
+ invariant(npub.length == 12);
+ const slenBuf = new ArrayBuffer(8);
+ const slenDv = new DataView(slenBuf);
+ const pad0 = new Uint8Array(16).fill(0);
+ const block0 = chacha20_ietf(64, k, npub);
+ const tag = new Uint8Array(16);
+ const p = new nacl.poly1305(block0);
+ const mlen = ct.length - tag.length;
+ p.update(ad, 0, ad.length);
+ p.update(pad0, 0, (0x10 - ad.length) & 0xf);
+ p.update(ct, 0, mlen);
+ p.update(pad0, 0, (0x10 - (mlen)) & 0xf);
+ slenDv.setBigUint64(0, BigInt(ad.length), true);
+ p.update(new Uint8Array(slenBuf), 0, 8);
+ slenDv.setBigUint64(0, BigInt(mlen), true);
+ p.update(new Uint8Array(slenBuf), 0, 8);
+ p.finish(tag, 0);
+ if (nacl.crypto_verify_16(tag, 0, ct, mlen) !== 0) {
+ return undefined;
+ };
+ const m = chacha20_ietf_xor(k, npub, ct.slice(0, mlen), 1);
+ return m;
+}
diff --git a/packages/taler-util/src/taleruris.test.ts b/packages/taler-util/src/taleruris.test.ts
@@ -594,7 +594,7 @@ test("taler-new add contact URI (stringify)", (t) => {
mailboxUri: "https://mailbox.example.com/ABCDEFG",
sourceBaseUrl: "https://taldir.example.com",
});
- t.deepEqual(url, "taler://add-exchange/exchange.demo.taler.net/");
+ t.deepEqual(url, "taler://add-contact/email/bob@example.com/https://mailbox.example.com/ABCDEFG?sourceBaseUrl=https%3A%2F%2Ftaldir.example.com");
});
/**
diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts
@@ -50,7 +50,7 @@ import {
import { canonicalizeBaseUrl } from "./helpers.js";
import { PaytoString, codecForPaytoString } from "./payto.js";
import { QrCodeSpec } from "./qr.js";
-import { AgeCommitmentProof } from "./taler-crypto.js";
+import { AgeCommitmentProof, HpkeSecretKey } from "./taler-crypto.js";
import { TalerErrorCode } from "./taler-error-codes.js";
import { TemplateParams } from "./taleruri.js";
import {
@@ -1373,26 +1373,28 @@ export interface ContactListResponse {
export interface MailboxConfiguration {
mailboxBaseUrl: string;
privateKey: EddsaPrivateKeyString;
+ privateEncryptionKey: string;
}
export const codecForMailboxConfiguration = (): Codec<MailboxConfiguration> =>
buildCodecForObject<MailboxConfiguration>()
.property("mailboxBaseUrl", codecForString())
+ .property("privateEncryptionKey", codecForString())
.property("privateKey", codecForEddsaPrivateKey())
.build("MailboxConfiguration");
export interface AddMailboxMessageRequest {
- message: MailboxMessage;
+ message: MailboxMessageRecord;
}
export interface DeleteMailboxMessageRequest {
- message: MailboxMessage;
+ message: MailboxMessageRecord;
}
export interface MailboxMessagesResponse {
- messages: MailboxMessage[];
+ messages: MailboxMessageRecord[];
}
export const codecForContactListItem = (): Codec<ContactEntry> =>
@@ -1781,49 +1783,22 @@ export interface ContactEntry {
source: string,
}
-export enum MailboxMessageType {
- PaymentRequestInvoice = "payment-request",
- MoneyTransfer = "money-transfer",
-}
-
-
-export interface MailboxMessageCommon {
- // the type of the message
- type: MailboxMessageType;
-
- // Message ID
- id: string;
+/**
+ * Record metadata for mailbox messages
+ */
+export interface MailboxMessageRecord {
// Origin mailbox
originMailboxBaseUrl: string;
- /**
- * Raw message body bytes
- */
- bodyRaw: string,
-
- /**
- * Sender hint
- */
- senderHint: string;
-
-}
+ // Time of download
+ downloadedAt: Timestamp;
-export type MailboxMessage =
- | MailboxMessagePaymentRequestInvoice
- | MailboxMessageMoneyTransfer
-
-export interface MailboxMessagePaymentRequestInvoice extends MailboxMessageCommon {
- type: MailboxMessageType.PaymentRequestInvoice;
+ // Taler URI in message
+ talerUri: string;
- payPullUri: string;
}
-export interface MailboxMessageMoneyTransfer extends MailboxMessageCommon {
- type: MailboxMessageType.MoneyTransfer;
-
- payPushUri: string;
-}
const codecForAuditorDenomSig = (): Codec<AuditorDenomSig> =>
buildCodecForObject<AuditorDenomSig>()
diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts
@@ -48,7 +48,6 @@ import {
DenominationInfo,
DenominationPubKey,
DonationReceiptSignature,
- EddsaPrivateKey,
EddsaPublicKeyString,
EddsaSignatureString,
ExchangeAuditor,
@@ -56,7 +55,6 @@ import {
ExchangeRefundRequest,
HashCodeString,
Logger,
- MailboxMessage,
MailboxConfiguration,
MerchantContractTokenDetails,
MerchantContractTokenKind,
@@ -82,6 +80,7 @@ import {
j2s,
stringToBytes,
stringifyScopeInfo,
+ MailboxMessageRecord,
} from "@gnu-taler/taler-util";
import { DbRetryInfo, TaskIdentifiers } from "./common.js";
import {
@@ -3187,9 +3186,9 @@ export const WalletStoresV1 = {
indexes: {},
}),
mailboxMessages: describeStoreV2({
- recordCodec: passthroughCodec<MailboxMessage>(),
+ recordCodec: passthroughCodec<MailboxMessageRecord>(),
storeName: "mailboxMessages",
- keyPath: ["originMailboxBaseUrl", "id"],
+ keyPath: ["originMailboxBaseUrl", "talerUri"],
indexes: {},
}),
mailboxConfigurations: describeStoreV2({
diff --git a/packages/taler-wallet-core/src/mailbox.ts b/packages/taler-wallet-core/src/mailbox.ts
@@ -26,16 +26,30 @@ import {
DeleteMailboxMessageRequest,
MailboxMessagesResponse,
Logger,
- MailboxMessage,
NotificationType,
EddsaPrivateKey,
createEddsaKeyPair,
encodeCrock,
MailboxConfiguration,
+ TalerMailboxInstanceHttpClient,
+ decodeCrock,
+ eddsaGetPublic,
+ sha512,
+ succeedOrThrow,
+ MailboxMessageRecord,
+ TalerProtocolTimestamp,
+ chacha20poly1305_ietf_decrypt,
+ OperationOk,
+ hpkeReceiverSetup,
+ stringToBytes,
+ hpkeOpenOneshot,
+ hpkeCreateSecretKey,
} from "@gnu-taler/taler-util";
import {
WalletExecutionContext,
} from "./wallet.js";
+import { HttpRequestLibrary } from "@gnu-taler/taler-util/http";
+import { OperationType } from "./instructedAmountConversion.js";
const logger = new Logger("mailbox.ts");
@@ -78,7 +92,7 @@ export async function deleteMailboxMessage(
],
},
async (tx) => {
- tx.mailboxMessages.delete ([req.message.originMailboxBaseUrl, req.message.id]);
+ tx.mailboxMessages.delete ([req.message.originMailboxBaseUrl, req.message.talerUri]);
tx.notify({
type: NotificationType.MailboxMessageDeleted,
message: req.message,
@@ -129,8 +143,13 @@ export async function initMailbox(
return res;
}
const keys = createEddsaKeyPair();
- const privKey = encodeCrock (keys.eddsaPriv);
- const mailboxConf = { mailboxBaseUrl: mailboxBaseUrl, privateKey: privKey };
+ const hpkeKey = encodeCrock(hpkeCreateSecretKey());
+ const privKey = encodeCrock(keys.eddsaPriv);
+ const mailboxConf: MailboxConfiguration = {
+ mailboxBaseUrl: mailboxBaseUrl,
+ privateKey: privKey,
+ privateEncryptionKey: hpkeKey,
+ };
await wex.db.runReadWriteTx(
{
storeNames: ["mailboxConfigurations"],
@@ -142,4 +161,60 @@ export async function initMailbox(
return mailboxConf;
}
-
+/**
+ * Refresh mailbox through HTTP
+ */
+export async function refreshMailbox(
+ wex: WalletExecutionContext,
+ mailboxConf: MailboxConfiguration,
+): Promise<MailboxMessageRecord[]> {
+ const mailboxClient = new TalerMailboxInstanceHttpClient(mailboxConf.mailboxBaseUrl, wex.http);
+ const privKey = decodeCrock(mailboxConf.privateKey);
+ const pubKey = eddsaGetPublic(privKey);
+ const h_address = encodeCrock(sha512(pubKey));
+ // Refresh message size
+ var message_size;
+ const resConf = await mailboxClient.getConfig();
+ switch (resConf.case) {
+ case "ok":
+ message_size = resConf.body.message_body_bytes;
+ break;
+ default:
+ return [];
+ }
+ const res = await mailboxClient.getMessages({h_mailbox: h_address});
+ 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
+ }
+ const numMessages = res.body.length / 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 uri = hpkeOpenOneshot(hpkeSk,
+ stringToBytes("mailbox-message"),
+ msg.slice(0, 4), // 4 byte header.
+ msg.slice(4));
+ if (!uri) {
+ continue; // FIXME log
+ }
+ records.push({
+ originMailboxBaseUrl: mailboxConf.mailboxBaseUrl,
+ talerUri: new TextDecoder().decode(uri),
+ downloadedAt: now,
+ });
+ }
+ return records;
+ }
+ return [];
+ default:
+ return [];
+ }
+}
diff --git a/packages/taler-wallet-webextension/src/wallet/Mailbox.tsx b/packages/taler-wallet-webextension/src/wallet/Mailbox.tsx
@@ -18,17 +18,17 @@ import {
assertUnreachable,
ScopeInfo,
NotificationType,
- MailboxMessage,
- MailboxMessageType,
decodeCrock,
eddsaGetPublic,
encodeCrock,
MailboxConfiguration,
+ TalerMailboxInstanceHttpClient,
+ sha512,
+ MailboxMessageRecord,
} from "@gnu-taler/taler-util";
import { SafeHandler } from "../mui/handlers.js";
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
-import { useTranslationContext } from "@gnu-taler/web-util/browser";
-import { Link } from "preact-router";
+import { BrowserFetchHttpLib, useTranslationContext } from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
import { useEffect, useState } from "preact/hooks";
import { ErrorAlertView } from "../components/CurrentAlerts.js";
@@ -36,9 +36,7 @@ import { Loading } from "../components/Loading.js";
import {
BoldLight,
Centered,
- RowBorderGray,
SmallText,
- SmallLightText,
LightText,
} from "../components/styled/index.js";
import { alertFromError, useAlertContext } from "../context/alert.js";
@@ -64,14 +62,20 @@ export function MailboxPage({
const [search, setSearch] = useState<string>();
// FIXME get from settings;
const mailboxBaseUrl = "http://localhost:11000";
+ var mailboxClient: TalerMailboxInstanceHttpClient;
+ var activeMailbox: MailboxConfiguration | undefined;
+ var h_address: string | undefined;
+ const httpClient = new BrowserFetchHttpLib();
+ mailboxClient = new TalerMailboxInstanceHttpClient(mailboxBaseUrl, httpClient);
const state = useAsyncAsHook(async () => {
const b = await api.wallet.call(WalletApiOperation.GetMailboxMessages, {});
- const activeMailbox = await api.wallet.call(WalletApiOperation.GetMailbox, mailboxBaseUrl);
+ activeMailbox = await api.wallet.call(WalletApiOperation.GetMailbox, mailboxBaseUrl);
// FIXME put this into the mailbox config directly?
const privKey = decodeCrock(activeMailbox.privateKey);
const pubKey = eddsaGetPublic(privKey);
+ h_address = encodeCrock(sha512(pubKey));
const mailboxUrl = activeMailbox.mailboxBaseUrl + '/' + encodeCrock(pubKey);
return {
messages: b.messages,
@@ -104,9 +108,22 @@ export function MailboxPage({
);
}
- const onDeleteMessage = async (m: MailboxMessage) => {
+ const onDeleteMessage = async (m: MailboxMessageRecord) => {
api.wallet.call(WalletApiOperation.DeleteMailboxMessage, { message: m }).then();
};
+ const onFetchMessages = async () => {
+ if (!h_address) {
+ return;
+ }
+ const httpClient = new BrowserFetchHttpLib();
+ const mailboxClient = new TalerMailboxInstanceHttpClient(mailboxBaseUrl, httpClient);
+ const messagesResp = await mailboxClient.getMessages({h_mailbox: h_address});
+ if (messagesResp.type != "ok") {
+ return;
+ }
+ messagesResp.body
+ state?.retry();
+ };
return (
<MessagesView
search={{
@@ -117,65 +134,48 @@ export function MailboxPage({
}}
messages={state.response.messages}
mailboxUrl={state.response.mailboxUrl}
+ onFetchMessages={onFetchMessages}
onDeleteMessage={onDeleteMessage}
/>
);
}
interface MessageProps {
- message: MailboxMessage;
- onDeleteMessage: (m: MailboxMessage) => Promise<void>;
+ message: MailboxMessageRecord;
+ onDeleteMessage: (m: MailboxMessageRecord) => Promise<void>;
}
function MailboxMessageLayout(props: MessageProps): VNode {
const { i18n } = useTranslationContext();
- switch (props.message.type) {
- case MailboxMessageType.MoneyTransfer:
- return (
- <Paper style={{ padding: 8 }}>
- <p>
- <span>Money transfer from {props.message.senderHint}</span>
- <SmallText style={{ marginTop: 5 }}>
- <i18n.Translate>URI:</i18n.Translate>: {props.message.payPushUri}
- </SmallText>
- </p>
- <Button variant="contained" onClick={() => { return props.onDeleteMessage(props.message)}} color="error">
- <i18n.Translate>Delete</i18n.Translate>
- </Button>
- </Paper>
- )
- case MailboxMessageType.PaymentRequestInvoice:
- return (
- <Paper style={{ padding: 8 }}>
- <p>
- <span>Payment request from {props.message.senderHint}</span>
- <SmallText style={{ marginTop: 5 }}>
- <i18n.Translate>URI</i18n.Translate>: {props.message.payPullUri}
- </SmallText>
- </p>
- <Button variant="contained" onClick={() => { return props.onDeleteMessage(props.message)}} color="error">
- <i18n.Translate>Delete</i18n.Translate>
- </Button>
- </Paper>
- )
- default:
- assertUnreachable(props.message);
-
+ 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}
+ </SmallText>
+ </p>
+ <Button variant="contained" onClick={() => { return props.onDeleteMessage(props.message)}} color="error">
+ <i18n.Translate>Delete</i18n.Translate>
+ </Button>
+ </Paper>
+ )
- }
}
export function MessagesView({
search,
messages,
mailboxUrl,
+ onFetchMessages,
onDeleteMessage,
}: {
search: TextFieldHandler;
- messages: MailboxMessage[];
+ messages: MailboxMessageRecord[];
mailboxUrl: string;
- onDeleteMessage: (m: MailboxMessage) => Promise<void>;
+ onFetchMessages: () => Promise<void>;
+ onDeleteMessage: (m: MailboxMessageRecord) => Promise<void>;
}): VNode {
const { i18n } = useTranslationContext();
async function copy(): Promise<void> {
@@ -188,14 +188,14 @@ export function MessagesView({
<p>
<SmallText style={{ marginTop: 5 }}>
<LightText>
- <i18n.Translate>Mailbox</i18n.Translate>: {mailboxUrl}
+ <i18n.Translate>Mailbox</i18n.Translate>: { mailboxUrl }
</LightText>
<Button onClick={copy as SafeHandler<void>}>
<i18n.Translate>copy</i18n.Translate>
</Button>
</SmallText>
</p>
- <Button variant="contained" color="error">
+ <Button variant="contained" color="error" onClick={onFetchMessages}>
<i18n.Translate>Fetch messages</i18n.Translate>
</Button>
</Centered>