taler-typescript-core

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

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:
Mpackages/taler-harness/src/integrationtests/test-wallet-mailbox-basic.ts | 20+++++++-------------
Mpackages/taler-util/src/http-client/mailbox.ts | 13++++++++-----
Mpackages/taler-util/src/nacl-fast.ts | 4++--
Mpackages/taler-util/src/notifications.ts | 6+++---
Mpackages/taler-util/src/taler-crypto.test.ts | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/taler-crypto.ts | 510++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackages/taler-util/src/taleruris.test.ts | 2+-
Mpackages/taler-util/src/types-taler-wallet.ts | 53++++++++++++++---------------------------------------
Mpackages/taler-wallet-core/src/db.ts | 7+++----
Mpackages/taler-wallet-core/src/mailbox.ts | 85++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mpackages/taler-wallet-webextension/src/wallet/Mailbox.tsx | 94++++++++++++++++++++++++++++++++++++++++----------------------------------------
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>