Internet-Draft | The GNU Taler Protocol | August 2025 |
Gütschow | Expires 13 February 2026 | [Page] |
[ TBW ]¶
This Internet-Draft is submitted in full conformance with the provisions of BCP 78 and BCP 79.¶
Internet-Drafts are working documents of the Internet Engineering Task Force (IETF). Note that other groups may also distribute working documents as Internet-Drafts. The list of current Internet-Drafts is at https://datatracker.ietf.org/drafts/current/.¶
Internet-Drafts are draft documents valid for a maximum of six months and may be updated, replaced, or obsoleted by other documents at any time. It is inappropriate to use Internet-Drafts as reference material or to cite them other than as "work in progress."¶
This Internet-Draft will expire on 13 February 2026.¶
Copyright (c) 2025 IETF Trust and the persons identified as the document authors. All rights reserved.¶
This document is subject to BCP 78 and the IETF Trust's Legal Provisions Relating to IETF Documents (https://trustee.ietf.org/license-info) in effect on the date of publication of this document. Please review these documents carefully, as they describe your rights and restrictions with respect to this document.¶
[ TBW ]¶
Beware that this document is still work-in-progress and may contain errors. Use at your own risk!¶
"abc"
denotes the literal string abc
encoded as ASCII [RFC20]¶
a | b
denotes the concatenation of a with b¶
len(a)
denotes the length in bytes of the byte string a¶
padZero(y, a)
denotes the byte string a, zero-padded to the length of y bytes¶
bits(x)
/bytes(x)
denotes the minimal number of bits/bytes necessary to represent the multiple precision integer x¶
uint(y, x)
denotes the y
least significant bits of the integer x
encoded in network byte order (big endian)¶
uint16(x)
/uint32(x)
/uint64(x)
/uint256(x)
/uint512(x)
is equivalent to uint(16, x)
/uint(32, x)
/uint(64, x)
/uint(256, x)
/uint(512, x)
, respectively¶
random(y)
denotes a randomly generated sequence of y bits¶
a * b (mod N)
/ a ** b (mod N)
denotes the multiplication / exponentiation of multiple precision integers a and b, modulo N¶
SHA-256(msg) -> hash Input: msg input message of length L < 2^61 octets Output: hash message digest of fixed length HashLen = 32 octets¶
hash
is the output of SHA-256 as per Sections 4.1, 5.1, 6.1, and 6.2 of [RFC6234].¶
SHA-512(msg) -> hash Input: msg input message of length L < 2^125 octets Output: hash message digest of fixed length HashLen = 64 octets¶
hash
is the output of SHA-512 as per Sections 4.2, 5.2, 6.3, and 6.4 of [RFC6234].¶
SHA-512-256(msg) -> hash Input: msg input message of length L < 2^125 octets Output: hash message digest of fixed length HashLen = 32 octets¶
The output hash
corresponds to the first 32 octets of the output of SHA-512 defined in Section 3.1.2:¶
temp = SHA-512(msg) hash = temp[0:31]¶
Note that this operation differs from SHA-512/256 as defined in [SHS] in the initial hash value.¶
The Hashed Key Derivation Function (HKDF) used in Taler is an instantiation of [RFC5869]
with two different hash functions for the Extract and Expand step as suggested in [HKDF]:
HKDF-Extract
uses HMAC-SHA512
, while HKDF-Expand
uses HMAC-SHA256
(cf. Section 3.2.1).¶
HKDF(salt, IKM, info, L) -> OKM Inputs: salt optional salt value (a non-secret random value); if not provided, it is set to a string of 64 zeros. IKM input keying material info optional context and application specific information (can be a zero-length string) L length of output keying material in octets (<= 255*32 = 8160) Output: OKM output keying material (of L octets)¶
The output OKM is calculated as follows:¶
PRK = HKDF-Extract(salt, IKM) with Hash = SHA-512 (HashLen = 64) OKM = HKDF-Expand(PRK, info, L) with Hash = SHA-256 (HashLen = 32)¶
Based on the HKDF defined in Section 3.3.1, this function returns an OKM that is smaller than a given multiple precision integer N.¶
HKDF-Mod(N, salt, IKM, info) -> OKM Inputs: N multiple precision integer salt optional salt value (a non-secret random value); if not provided, it is set to a string of 64 zeros. IKM input keying material info optional context and application specific information (can be a zero-length string) Output: OKM output keying material (smaller than N)¶
The final output OKM
is determined deterministically based on a counter initialized at zero.¶
counter = 0 do until OKM < N: x = HKDF(salt, IKM, info | uint16(counter), bytes(N)) OKM = uint(bits(N), x) counter += 1¶
RSA-FDH(msg, pubkey) -> fdh Inputs: msg message pubkey RSA public key consisting of modulus N and public exponent e Output: fdh full-domain hash of msg over pubkey.N¶
fdh
is calculated based on HKDF-Mod from Section 3.3.2 as follows:¶
info = "RSA-FDA FTpsW!" salt = uint16(bytes(pubkey.N)) | uint16(bytes(pubkey.e)) | pubkey.N | pubkey.e fdh = HKDF-Mod(pubkey.N, salt, msg, info)¶
The resulting fdh
can be used to test against a malicious RSA pubkey
by verifying that the greatest common denominator (gcd) of fdh
and pubkey.N
is 1.¶
RSA-FDH-Derive(bks, pubkey) -> out Inputs: bks blinding key secret of length L = 32 octets pubkey RSA public key consisting of modulus N and public exponent e Output: out full-domain hash of bks over pubkey.N¶
out
is calculated based on HKDF-Mod from Section 3.3.2 as follows:¶
info = "Blinding KDF" salt = "Blinding KDF extractor HMAC key" fdh = HKDF-Mod(pubkey.N, salt, bks, info)¶
RSA-FDH-Blind(msg, bks, pubkey) -> out Inputs: msg message bks blinding key secret of length L = 32 octets pubkey RSA public key consisting of modulus N and public exponent e Output: out message blinded for pubkey¶
out
is calculated based on RSA-FDH from Section 3.5.1 as follows:¶
data = RSA-FDH(msg, pubkey) r = RSA-FDH-Derive(bks, pubkey) r_e = r ** pubkey.e (mod pubkey.N) out = r_e * data (mod pubkey.N)¶
RSA-FDH-Sign(data, privkey) -> sig Inputs: data data to be signed, an integer smaller than privkey.N privkey RSA private key consisting of modulus N and private exponent d Output: sig signature on data by privkey¶
sig
is calculated as follows:¶
sig = data ** privkey.d (mod privkey.N)¶
RSA-FDH-Unblind(sig, bks, pubkey) -> out Inputs: sig blind signature bks blinding key secret of length L = 32 octets pubkey RSA public key consisting of modulus N and public exponent e Output: out unblinded signature¶
out
is calculated as follows:¶
r = RSA-FDH-Derive(bks, pubkey) r_inv = inverse of r (mod pubkey.N) out = sig * r_inv (mod pubkey.N)¶
RSA-FDH-Verify(msg, sig, pubkey) -> out Inputs: msg message sig signature of pubkey over msg pubkey RSA public key consisting of modulus N and public exponent e Output: out true, if sig is a valid signature¶
out
is calculated based on RSA-FDH from Section 3.5.1 as follows:¶
data = RSA-FDH(msg, pubkey) exp = sig ** pubkey.e (mod pubkey.N) out = (data == exp)¶
Amounts are represented in Taler as positive fixed-point values
consisting of value
as the non-negative integer part of the base currency,
the fraction
given in units of one hundred millionth (1e-8) of the base currency,
and currency
as the 3-11 ASCII characters identifying the currency.¶
Whenever used in the protocol, the binary representation of an amount
is
uint64(amount.value) | uint32(amount.fraction) | padZero(12, amount.currency)
.¶
Absolute timestamps are represented as uint64(x)
where x
corresponds to
the microseconds since 1970-01-01 00:00 CEST
(the UNIX epoch).
The special value 0xFFFFFFFFFFFFFFFF
represents "never".¶
All messages to be signed in Taler start with a header containing their size and a fixed signing context (purpose) as registered by GANA in the GNUnet Signature Purposes registry. All Taler-related purposes start at 1000 and can be found in the registry sources.¶
Sign-Msg(purpose, msg) -> out Inputs: purpose signature purpose as registered at GANA msg message content (excl. header) to be signed Output: out complete message (incl. header) to be signed¶
out
is formed as follows:¶
out = uint32(len(msg)) | uint32(purpose) | msg¶
// todo: explain persist, check, knows, sum¶
The wallet generates n > 0
coins (coin[i]
) and requests n
signatures (blind_sig[i]
) from the exchange,
attributing value to the coins according to n
chosen denominations (denom[i]
).
The total value and withdrawal fee (defined by the exchange per denomination)
must be smaller or equal to the amount stored in the single reserve used for withdrawal.¶
wallet exchange knows denom[i].pub knows denom[i].priv | | +----------------------------+ | | (1) reserve key generation | | +----------------------------+ | | | |----------- (bank transfer) ----------->| | (subject: reserve.pub, amount: value) | | | | +------------------------------+ | | persist (reserve.pub, value) | | +------------------------------+ | | +----------------------------------+ | | (2) coin generation and blinding | | +----------------------------------+ | | | |-------------- /withdraw -------------->| | (reserve.pub, coin[i].h_denom, | | blind_coin[i], sig0) | | | | +-------------------------------+ | | (3) coin issuance and signing | | +-------------------------------+ | | |<----------- blind_sig[i] --------------| | | +---------------------+ | | (4) coin unblinding | | +---------------------+ |¶
where (for RSA, without age-restriction)¶
(1) reserve key generation (wallet) reserve = EdDSA-Keygen() persist (reserve, value)¶
The wallet derives coins and blinding secrets using a HKDF from a master secret per withdrawal and an integer index. This is strictly speaking an implementation detail since the master secret is never revealed to any other party, and might be chosen to be implemented differently.¶
(2) coin generation and blinding (wallet) master_secret = random(256) persist master_secret coin_seed[i] = HKDF(uint32(i), master_secret, "taler-withdrawal-coin-derivation", 64) blind_secret[i] = coin_seed[i][32:] coin[i].priv = coin_seed[i][:32] coin[i].pub = EdDSA-GetPub(coin[i].priv) coin[i].h_denom = SHA-512(uint32(0) | uint32(1) | denom[i].pub) blind_coin[i] = RSA-FDH-Blind(SHA-512(coin[i].pub), blind_secret[i], denom[i].pub) msg0 = Sign-Msg(WALLET_RESERVE_WITHDRAW, ( sum(denom[i].value) | sum(denom[i].fee_withdraw) | SHA-512( SHA-512(denom[i].pub) | uint32(0x1) | blind_coin[i] ) | uint256(0x0) | uint32(0x0) | uint32(0x0) )) sig0 = EdDSA-Sign(reserve.priv, msg0)¶
(3) coin issuance and signing (exchange) denom[i] = Denom-Lookup(coin[i].h_denom) check denom[i].pub known and not withdrawal-expired check EdDSA-Verify(reserve.pub, msg, sig) check reserve KYC status ok or not needed check reserve.balance >= sum(denom[i].value + denom[i].fee_withdraw) reserve.balance -= sum(denom[i].value + denom[i].fee_withdraw) blind_sig[i] = RSA-FDH-Sign(blind_coin[i], denom[i].priv) persist withdrawal¶
(4) coin unblinding (wallet) coin[i].sig = RSA-FDH-Unblind(blind_sig[i], blind_secret[i], denom[i].pub) check RSA-FDH-Verify(SHA-512(coin[i].pub), coin[i].sig, denom[i].pub) persist (coin[i], blind_secret[i])¶
wallet merchant knows merchant.pub knows merchant.priv knows valid coin[i] knows exchange, payto | | | +----------------------+ | | (1) order generation | | +----------------------+ | | |<-------- (order.{id,token?}) ----------| | | +----------------------+ | | (2) nonce generation | | +----------------------+ | | | |------- /orders/{order.id}/claim ------>| | (nonce.pub, token?) | | | | +-------------------------+ | | (3) contract generation | | +-------------------------+ | | |<----------- contract, sig -------------| | | +-------------------------+ | | (4) payment preparation | | +-------------------------+ | | | |------- /orders/{order.id}/pay -------->| | (deposit[i]) | | | | +-------------------+ | | (5) deposit check | | +-------------------+ | | |<---------------- sig ------------------| | | +--------------------------+ | | (6) payment verification | | +--------------------------+ |¶
where (without age restriction, policy and wallet data hash)¶
(1) order generation (merchant) wire_salt = random(128) persist order = (id, price, info, token?, wire_salt)¶
(2) nonce generation (wallet) nonce = EdDSA-Keygen() persist nonce.priv¶
Note that the private key of nonce
is currently not used anywhere in the protocol.
However, it could be used in the future to prove ownership of an order transaction,
enabling use-cases such as "unclaiming" or transferring an order to another person,
or proving the payment without resorting to the individual coins.¶
(3) contract generation (merchant) h_wire = HKDF(wire_salt, payto, "merchant-wire-signature", 64) determine timestamp, refund_deadline, wire_deadline contract = (order.{id,price,info,token?}, exchange, h_wire, timestamp, refund_deadline, wire_deadline) check contract.order.token = token? contract.nonce = nonce.pub persist contract h_contract = SHA-512(canonicalJSON(contract)) msg = Sign-Msg(MERCHANT_CONTRACT, h_contract) sig = EdDSA-Sign(merchant.priv, msg)¶
(4) payment preparation (wallet) check EdDSA-Verify(merchant.pub, msg, sig) check contract.nonce = nonce TODO: double-check extra hash check? deposit = CoinSelection(contract.{exchange,price}) TODO: include MarkDirty here msg[i] = Sign-Msg(WALLET_COIN_DEPOSIT, ( h_contract | uint256(0x0) | uint512(0x0) | contract.h_wire | coin[i].h_denom | contract.timestamp | contract.refund_deadline | deposit[i] + denom[i].fee_deposit | denom[i].fee_deposit | merchant.pub | uint512(0x0) )) sig[i] = EdDSA-Sign(coin[i].priv, msg[i]) deposit[i] = (coin[i].{pub,sig,h_denom}, fraction[i], sig[i]) persist (contract, sig[i], deposit[i])¶
(5) deposit check (merchant) check sum(deposit[i].fraction) == contract.price check Deposit(deposit)[i] msg = Sign-Msg(MERCHANT_PAYMENT_OK, h_contract) sig = EdDSA-Sign(merchant.priv, msg)¶
(6) payment verification check EdDSA-Verify(merchant.pub, msg, sig)¶
merchant/wallet exchange knows exchange.pub knows exchange.priv knows merchant.pub knows *denom.pub knows payto, wire_salt, sig2, h_contract knows contract, *deposit from Payment | | |----------- /batch-deposit ------------>| | (contract.{timestamp,wire_deadline,refund_deadline} | h_contract, merchant.pub, sig2, | | payto, *deposit, wire_salt) | | | | *denom = Denom-Lookup(*deposit.coin.h_denom) | check *denom.pub known and not deposit-expired | contract.h_wire = HKDF(wire_salt, payto, | "merchant-wire-signature", 64) | check *EdDSA-Verify(deposit.coin.pub, msg1, sig1) | check *RSA-FDH-Verify(SHA-512(deposit.coin.pub), | deposit.coin.sig, denom.pub) | check not overspending | persist deposit-record, mark-spent | schedule bank transfer | exchange_timestamp = now() | sig3 = EdDSA-Sign(exchange.priv, msg3) | | |<----- sig3, exchange_timestamp --------| | | check EdDSA-Verify(exchange.pub, msg3, sig3) |¶
where msg3
is formed as follows:¶
msg3 = Sign-Msg(EXCHANGE_CONFIRM_DEPOSIT, ( h_contract | contract.h_wire | uint512(0x0) | exchange_timestamp | contract.wire_deadline | contract.refund_deadline | sum(*deposit.fraction - *denom.fee_deposit) | SHA-512(*deposit.sig1) | merchant.pub ))¶
[ TBD ]¶
None.¶
[ TBD ]¶
This work was supported in part by the German Federal Ministry of Education and Research (BMBF) within the project Concrete Contracts.¶