commit e3aec1f5651cb57938af246f2ec095429c6c3d33 parent 5e1fde0445c9a3603ae6a990f8baefcce4cc04da Author: Antoine A <> Date: Wed, 4 Mar 2026 10:36:10 +0100 common: support new wire gateway authorization fields Diffstat:
27 files changed, 458 insertions(+), 153 deletions(-)
diff --git a/contrib/nexus.conf b/contrib/nexus.conf @@ -121,10 +121,22 @@ AUTH_METHOD = bearer # Token for bearer authentication scheme TOKEN = -[nexus-httpd-wire-transfer-api] -# Whether to serve the Wire Transfer API +[nexus-httpd-wire-transfer-gateway-api] +# Whether to serve the Wire Transfer Gateway API ENABLED = NO +# Authentication scheme, this can either can be basic, bearer or none. +AUTH_METHOD = none + +# User name for basic authentication scheme +# USERNAME = + +# Password for basic authentication scheme +# PASSWORD = + +# Token for bearer authentication scheme +# TOKEN = + [nexus-httpd-revenue-api] # Whether to serve the Revenue API ENABLED = NO diff --git a/database-versioning/libeufin-bank-0015.sql b/database-versioning/libeufin-bank-0015.sql @@ -37,10 +37,15 @@ ALTER TABLE transfer_operations ADD COLUMN metadata TEXT; -- Replace unused wad type with new mapping type ALTER TYPE taler_incoming_type RENAME VALUE 'wad' TO 'map'; +ALTER TABLE taler_exchange_incoming + ADD COLUMN authorization_pub BYTEA CHECK (LENGTH(authorization_pub)=32), + ADD COLUMN authorization_sig BYTEA CHECK (LENGTH(authorization_sig)=64); + CREATE TABLE prepared_transfers ( type taler_incoming_type NOT NULL, account_pub BYTEA NOT NULL CHECK (LENGTH(account_pub)=32), authorization_pub BYTEA UNIQUE NOT NULL CHECK (LENGTH(authorization_pub)=32), + authorization_sig BYTEA NOT NULL CHECK (LENGTH(authorization_sig)=64), recurrent BOOLEAN NOT NULL, withdrawal_id INT8 UNIQUE REFERENCES taler_withdrawal_operations(withdrawal_id), registered_at INT8 NOT NULL, diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql @@ -630,7 +630,9 @@ CREATE FUNCTION register_incoming( IN in_tx_row_id INT8, IN in_type taler_incoming_type, IN in_metadata BYTEA, - IN in_account_id INT8 + IN in_account_id INT8, + IN in_authorization_pub BYTEA, + IN in_authorization_sig BYTEA ) RETURNS void LANGUAGE plpgsql AS $$ @@ -641,11 +643,15 @@ BEGIN INSERT INTO taler_exchange_incoming ( metadata, bank_transaction, - type + type, + authorization_pub, + authorization_sig ) VALUES ( in_metadata, in_tx_row_id, - in_type + in_type, + in_authorization_pub, + in_authorization_sig ); -- Update stats IF in_type = 'reserve' THEN @@ -718,17 +724,18 @@ CREATE FUNCTION make_incoming( ) LANGUAGE plpgsql AS $$ DECLARE -local_mapped_by BYTEA; local_withdrawal_uuid UUID; +local_authorization_pub BYTEA; +local_authorization_sig BYTEA; BEGIN out_pending=FALSE; -- Resolve mapping logic IF in_type = 'map' THEN - SELECT prepared_transfers.type, account_pub, authorization_pub, withdrawal_uuid, + SELECT prepared_transfers.type, account_pub, authorization_pub, authorization_sig, withdrawal_uuid, bank_transaction_id IS NOT NULL AND NOT recurrent, bank_transaction_id IS NOT NULL AND recurrent - INTO in_type, in_metadata, local_mapped_by, local_withdrawal_uuid, out_mapping_reuse, out_pending + INTO in_type, in_metadata, local_authorization_pub, local_authorization_sig, local_withdrawal_uuid, out_mapping_reuse, out_pending FROM prepared_transfers LEFT JOIN taler_withdrawal_operations USING (withdrawal_id) WHERE authorization_pub = in_metadata; @@ -772,15 +779,15 @@ END IF; IF out_pending THEN -- Delay talerable registration until mapping again INSERT INTO pending_recurrent_incoming_transactions (bank_transaction_id, debtor_account_id, authorization_pub) - VALUES (out_credit_row_id, in_debtor_account_id, local_mapped_by); + VALUES (out_credit_row_id, in_debtor_account_id, local_authorization_pub); ELSE - IF local_mapped_by IS NOT NULL THEN + IF local_authorization_pub IS NOT NULL THEN UPDATE prepared_transfers SET bank_transaction_id = out_credit_row_id - WHERE authorization_pub = local_mapped_by; + WHERE authorization_pub = local_authorization_pub; PERFORM abort_taler_withdrawal(local_withdrawal_uuid); END IF; - PERFORM register_incoming(out_credit_row_id, in_type, in_metadata, in_creditor_account_id); + PERFORM register_incoming(out_credit_row_id, in_type, in_metadata, in_creditor_account_id, local_authorization_pub, local_authorization_sig); END IF; END $$; @@ -2285,6 +2292,7 @@ CREATE FUNCTION register_prepared_transfers ( IN in_type taler_incoming_type, IN in_account_pub BYTEA, IN in_authorization_pub BYTEA, + IN in_authorization_sig BYTEA, IN in_recurrent BOOLEAN, IN in_amount taler_amount, IN in_timestamp INT8, @@ -2357,7 +2365,7 @@ IF in_recurrent THEN RETURNING bank_transaction_id INTO talerable_tx; IF FOUND THEN - PERFORM register_incoming(talerable_tx, in_type, in_account_pub, exchange_account_id); + PERFORM register_incoming(talerable_tx, in_type, in_account_pub, exchange_account_id, in_authorization_pub, in_authorization_sig); END IF; ELSE -- Bounce all pending @@ -2399,6 +2407,7 @@ INSERT INTO prepared_transfers ( type, account_pub, authorization_pub, + authorization_sig, recurrent, registered_at, bank_transaction_id, @@ -2407,6 +2416,7 @@ INSERT INTO prepared_transfers ( in_type, in_account_pub, in_authorization_pub, + in_authorization_sig, in_recurrent, in_timestamp, talerable_tx, @@ -2418,7 +2428,8 @@ DO UPDATE SET recurrent = EXCLUDED.recurrent, registered_at = EXCLUDED.registered_at, bank_transaction_id = EXCLUDED.bank_transaction_id, - withdrawal_id = EXCLUDED.withdrawal_id; + withdrawal_id = EXCLUDED.withdrawal_id, + authorization_sig = EXCLUDED.authorization_sig; END $$; CREATE FUNCTION delete_prepared_transfers ( diff --git a/database-versioning/libeufin-nexus-0014.sql b/database-versioning/libeufin-nexus-0014.sql @@ -28,10 +28,15 @@ ALTER TABLE talerable_outgoing_transactions ADD COLUMN metadata TEXT; -- Replace unused wad type with new mapping type ALTER TYPE taler_incoming_type RENAME VALUE 'wad' TO 'map'; +ALTER TABLE talerable_incoming_transactions + ADD COLUMN authorization_pub BYTEA CHECK (LENGTH(authorization_pub)=32), + ADD COLUMN authorization_sig BYTEA CHECK (LENGTH(authorization_sig)=64); + CREATE TABLE prepared_transfers ( type taler_incoming_type NOT NULL, account_pub BYTEA NOT NULL CHECK (LENGTH(account_pub)=32), authorization_pub BYTEA UNIQUE NOT NULL CHECK (LENGTH(authorization_pub)=32), + authorization_sig BYTEA NOT NULL CHECK (LENGTH(authorization_sig)=64), recurrent BOOLEAN NOT NULL, reference_number TEXT UNIQUE NOT NULL CHECK(reference_number ~ '^\d{27}$'), registered_at INT8 NOT NULL, diff --git a/database-versioning/libeufin-nexus-procedures.sql b/database-versioning/libeufin-nexus-procedures.sql @@ -270,7 +270,8 @@ local_ref TEXT; local_amount taler_amount; local_subject TEXT; local_debit_payto TEXT; -local_mapped_by BYTEA; +local_authorization_pub BYTEA; +local_authorization_sig BYTEA; BEGIN IF in_credit_fee = (0, 0)::taler_amount THEN in_credit_fee = NULL; @@ -290,8 +291,10 @@ out_found=FOUND; IF NOT out_found OR NOT out_talerable THEN -- Resolve mapping logic IF in_type = 'map' OR in_qr_reference_number IS NOT NULL THEN - SELECT type, account_pub, authorization_pub, incoming_transaction_id IS NOT NULL AND NOT recurrent, incoming_transaction_id IS NOT NULL AND recurrent - INTO in_type, in_metadata, local_mapped_by, out_mapping_reuse, out_pending + SELECT type, account_pub, authorization_pub, authorization_sig, + incoming_transaction_id IS NOT NULL AND NOT recurrent, + incoming_transaction_id IS NOT NULL AND recurrent + INTO in_type, in_metadata, local_authorization_pub, local_authorization_sig, out_mapping_reuse, out_pending FROM prepared_transfers WHERE authorization_pub = in_metadata OR reference_number = in_qr_reference_number; out_unknown_mapping = NOT FOUND; @@ -362,22 +365,26 @@ IF in_type IS NOT NULL AND NOT out_talerable AND out_bounce_id IS NULL THEN If out_pending THEN -- Delay talerable registration until mapping again INSERT INTO pending_recurrent_incoming_transactions (incoming_transaction_id, authorization_pub) - VALUES (out_tx_id, local_mapped_by); + VALUES (out_tx_id, local_authorization_pub); ELSE - IF local_mapped_by IS NOT NULL THEN + IF local_authorization_pub IS NOT NULL THEN UPDATE prepared_transfers SET incoming_transaction_id = out_tx_id - WHERE authorization_pub = local_mapped_by; + WHERE authorization_pub = local_authorization_pub; END IF; -- We cannot use ON CONFLICT here because conversion use a trigger before insertion that isn't idempotent INSERT INTO talerable_incoming_transactions ( incoming_transaction_id ,type ,metadata + ,authorization_pub + ,authorization_sig ) VALUES ( out_tx_id ,in_type ,in_metadata + ,local_authorization_pub + ,local_authorization_sig ); PERFORM pg_notify('nexus_incoming_tx', out_tx_id::text); out_talerable=TRUE; @@ -660,6 +667,7 @@ CREATE FUNCTION register_prepared_transfers ( IN in_type taler_incoming_type, IN in_account_pub BYTEA, IN in_authorization_pub BYTEA, + IN in_authorization_sig BYTEA, IN in_recurrent BOOLEAN, IN in_reference_number TEXT, IN in_timestamp INT8, @@ -713,8 +721,8 @@ IF in_recurrent THEN ) RETURNING incoming_transaction_id ) - INSERT INTO talerable_incoming_transactions (incoming_transaction_id, type, metadata) - SELECT incoming_transaction_id, in_type, in_account_pub + INSERT INTO talerable_incoming_transactions (incoming_transaction_id, type, metadata, authorization_pub, authorization_sig) + SELECT moved_tx.incoming_transaction_id, in_type, in_account_pub, in_authorization_pub, in_authorization_sig FROM moved_tx RETURNING incoming_transaction_id INTO talerable_tx; IF talerable_tx IS NOT NULL THEN @@ -733,6 +741,7 @@ INSERT INTO prepared_transfers ( type, account_pub, authorization_pub, + authorization_sig, recurrent, reference_number, registered_at, @@ -741,6 +750,7 @@ INSERT INTO prepared_transfers ( in_type, in_account_pub, in_authorization_pub, + in_authorization_sig, in_recurrent, in_reference_number, in_timestamp, @@ -752,7 +762,8 @@ DO UPDATE SET recurrent = EXCLUDED.recurrent, reference_number = EXCLUDED.reference_number, registered_at = EXCLUDED.registered_at, - incoming_transaction_id = EXCLUDED.incoming_transaction_id; + incoming_transaction_id = EXCLUDED.incoming_transaction_id, + authorization_sig = EXCLUDED.authorization_sig; END $$; CREATE FUNCTION delete_prepared_transfers ( diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/Constants.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/Constants.kt @@ -39,6 +39,6 @@ const val MAX_TOKEN_CREATION_ATTEMPTS: Int = 5 const val MAX_ACTIVE_CHALLENGES: Int = 5 // API version -const val COREBANK_API_VERSION: String = "11:0:1" +const val COREBANK_API_VERSION: String = "12:0:0" const val CONVERSION_API_VERSION: String = "2:0:1" const val INTEGRATION_API_VERSION: String = "5:0:5" diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/WireTransferApi.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/WireTransferApi.kt @@ -36,7 +36,7 @@ import java.time.Instant import java.time.Duration fun Routing.wireTransferApi(db: Database, cfg: BankConfig) { - get("/accounts/{USERNAME}/taler-wire-transfer/config") { + get("/accounts/{USERNAME}/taler-wire-transfer-gateway/config") { call.respond( WireTransferConfig( currency = cfg.regionalCurrency, @@ -44,12 +44,12 @@ fun Routing.wireTransferApi(db: Database, cfg: BankConfig) { ) ) } - post("/accounts/{USERNAME}/taler-wire-transfer/registration") { + post("/accounts/{USERNAME}/taler-wire-transfer-gateway/registration") { val username = call.pathUsername val req = call.receive<SubjectRequest>(); cfg.checkRegionalCurrency(req.credit_amount) - if (!CryptoUtil.checkEdssaSignature(req.account_pub.raw, req.authorization_signature, req.authorization_pub)) + if (!CryptoUtil.checkEdssaSignature(req.account_pub.raw, req.authorization_sig, req.authorization_pub)) throw conflict( "invalid signature", TalerErrorCode.BANK_BAD_SIGNATURE @@ -60,6 +60,7 @@ fun Routing.wireTransferApi(db: Database, cfg: BankConfig) { req.type, req.account_pub, req.authorization_pub, + req.authorization_sig, req.recurrent, req.credit_amount, Instant.now() @@ -84,7 +85,7 @@ fun Routing.wireTransferApi(db: Database, cfg: BankConfig) { } } } - delete("/accounts/{USERNAME}/taler-wire-transfer/registration") { + delete("/accounts/{USERNAME}/taler-wire-transfer-gateway/registration") { val req = call.receive<Unregistration>(); val timestamp = Instant.parse(req.timestamp) @@ -95,7 +96,7 @@ fun Routing.wireTransferApi(db: Database, cfg: BankConfig) { TalerErrorCode.BANK_OLD_TIMESTAMP ) - if (!CryptoUtil.checkEdssaSignature(req.timestamp.toByteArray(), req.authorization_signature, req.authorization_pub)) + if (!CryptoUtil.checkEdssaSignature(req.timestamp.toByteArray(), req.authorization_sig, req.authorization_pub)) throw conflict( "invalid signature", TalerErrorCode.BANK_BAD_SIGNATURE diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/ExchangeDAO.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/ExchangeDAO.kt @@ -40,6 +40,8 @@ class ExchangeDAO(private val db: Database) { ,debtor_name ,type ,metadata + ,authorization_pub + ,authorization_sig FROM taler_exchange_incoming AS tfr JOIN bank_account_transactions AS txs ON bank_transaction=txs.bank_transaction_id @@ -53,6 +55,8 @@ class ExchangeDAO(private val db: Database) { amount = it.getAmount("amount", db.bankCurrency), debit_account = it.getBankPayto("debtor_payto", "debtor_name", db.ctx), reserve_pub = EddsaPublicKey(it.getBytes("metadata")), + authorization_pub = it.getOptKey("authorization_pub"), + authorization_sig = it.getOptSig("authorization_sig") ) IncomingType.kyc -> IncomingKycAuthTransaction( row_id = it.getLong("bank_transaction_id"), @@ -60,6 +64,8 @@ class ExchangeDAO(private val db: Database) { amount = it.getAmount("amount", db.bankCurrency), debit_account = it.getBankPayto("debtor_payto", "debtor_name", db.ctx), account_pub = EddsaPublicKey(it.getBytes("metadata")), + authorization_pub = it.getOptKey("authorization_pub"), + authorization_sig = it.getOptSig("authorization_sig") ) IncomingType.map -> throw UnsupportedOperationException() } diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/TransferDAO.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/TransferDAO.kt @@ -40,6 +40,7 @@ class TransferDAO(private val db: Database) { type: TransferType, accountPub: EddsaPublicKey, authPub: EddsaPublicKey, + authSig: EddsaSignature, recurrent: Boolean, amount: TalerAmount, timestamp: Instant @@ -47,7 +48,7 @@ class TransferDAO(private val db: Database) { """ SELECT out_unknown_account, out_not_exchange, out_reserve_pub_reuse, out_withdrawal_uuid FROM register_prepared_transfers ( - ?, ?::taler_incoming_type, ?, ?, ?, (?, ?)::taler_amount, ?, ? + ?, ?::taler_incoming_type, ?, ?, ?, ?, (?, ?)::taler_amount, ?, ? ) """ ) { @@ -55,6 +56,7 @@ class TransferDAO(private val db: Database) { bind(type) bind(accountPub) bind(authPub) + bind(authSig) bind(recurrent) bind(amount) bind(timestamp) diff --git a/libeufin-bank/src/test/kotlin/WireTransferApiTest.kt b/libeufin-bank/src/test/kotlin/WireTransferApiTest.kt @@ -29,13 +29,13 @@ import java.util.UUID import kotlin.test.* class WireTransferApiTest { - // GET /accounts/{USERNAME}/taler-wire-transfer/config + // GET /accounts/{USERNAME}/taler-wire-transfer-gateway/config @Test fun config() = bankSetup { - client.get("/accounts/merchant/taler-wire-transfer/config").assertOkJson<WireTransferConfig>() + client.get("/accounts/merchant/taler-wire-transfer-gateway/config").assertOkJson<WireTransferConfig>() } - // POST /accounts/{USERNAME}/taler-wire-transfer/registration + // POST /accounts/{USERNAME}/taler-wire-transfer-gateway/registration @Test fun registration() = bankSetup { val (priv, pub) = EddsaPublicKey.randEdsaKeyPair() @@ -46,14 +46,14 @@ class WireTransferApiTest { "alg" to "ECDSA" "account_pub" to pub "authorization_pub" to pub - "authorization_signature" to CryptoUtil.eddsaSign(pub.raw, priv) + "authorization_sig" to CryptoUtil.eddsaSign(pub.raw, priv) "recurrent" to false } val simpleSubject = TransferSubject.Simple("Taler MAP:$pub", amount) // Valid - val subjects = client.post("/accounts/exchange/taler-wire-transfer/registration") { + val subjects = client.post("/accounts/exchange/taler-wire-transfer-gateway/registration") { json(valid_req) }.assertOkJson<SubjectResult> { assertEquals(it.subjects[1], simpleSubject) @@ -61,14 +61,14 @@ class WireTransferApiTest { }.subjects // Idempotent - client.post("/accounts/exchange/taler-wire-transfer/registration") { + client.post("/accounts/exchange/taler-wire-transfer-gateway/registration") { json(valid_req) }.assertOkJson<SubjectResult> { assertEquals(it.subjects, subjects) } // KYC has a different withdrawal uri - client.post("/accounts/exchange/taler-wire-transfer/registration") { + client.post("/accounts/exchange/taler-wire-transfer-gateway/registration") { json(valid_req) { "type" to "kyc" } @@ -79,7 +79,7 @@ class WireTransferApiTest { } // Recurrent only has simple subject - client.post("/accounts/exchange/taler-wire-transfer/registration") { + client.post("/accounts/exchange/taler-wire-transfer-gateway/registration") { json(valid_req) { "recurrent" to true } @@ -88,19 +88,19 @@ class WireTransferApiTest { } // Bad signature - client.post("/accounts/exchange/taler-wire-transfer/registration") { + client.post("/accounts/exchange/taler-wire-transfer-gateway/registration") { json(valid_req) { - "authorization_signature" to EddsaSignature.rand() + "authorization_sig" to EddsaSignature.rand() } }.assertConflict(TalerErrorCode.BANK_BAD_SIGNATURE) // Not exchange - client.post("/accounts/merchant/taler-wire-transfer/registration") { + client.post("/accounts/merchant/taler-wire-transfer-gateway/registration") { json(valid_req) }.assertConflict(TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE) // Unknown account - client.post("/accounts/unknown/taler-wire-transfer/registration") { + client.post("/accounts/unknown/taler-wire-transfer-gateway/registration") { json(valid_req) }.assertNotFound(TalerErrorCode.BANK_UNKNOWN_ACCOUNT) @@ -108,7 +108,7 @@ class WireTransferApiTest { assertBalance("exchange", "+KUDOS:0") // Non recurrent accept on then bounce - client.post("/accounts/exchange/taler-wire-transfer/registration") { + client.post("/accounts/exchange/taler-wire-transfer-gateway/registration") { json(valid_req) { "type" to "reserve" } @@ -122,7 +122,7 @@ class WireTransferApiTest { } // Withdrawal is aborted on completion - client.post("/accounts/exchange/taler-wire-transfer/registration") { + client.post("/accounts/exchange/taler-wire-transfer-gateway/registration") { json(valid_req) { "type" to "kyc" } @@ -139,10 +139,10 @@ class WireTransferApiTest { // Recurrent accept one and delay others val newKey = EddsaPublicKey.randEdsaKey() - client.post("/accounts/exchange/taler-wire-transfer/registration") { + client.post("/accounts/exchange/taler-wire-transfer-gateway/registration") { json(valid_req) { "account_pub" to newKey - "authorization_signature" to CryptoUtil.eddsaSign(newKey.raw, priv) + "authorization_sig" to CryptoUtil.eddsaSign(newKey.raw, priv) "recurrent" to true } } @@ -156,19 +156,19 @@ class WireTransferApiTest { // Complete pending on recurrent update val kycKey = EddsaPublicKey.randEdsaKey() - client.post("/accounts/exchange/taler-wire-transfer/registration") { + client.post("/accounts/exchange/taler-wire-transfer-gateway/registration") { json(valid_req) { "type" to "kyc" "account_pub" to kycKey - "authorization_signature" to CryptoUtil.eddsaSign(kycKey.raw, priv) + "authorization_sig" to CryptoUtil.eddsaSign(kycKey.raw, priv) "recurrent" to true } }.assertOkJson<SubjectResult>() - client.post("/accounts/exchange/taler-wire-transfer/registration") { + client.post("/accounts/exchange/taler-wire-transfer-gateway/registration") { json(valid_req) { "type" to "reserve" "account_pub" to kycKey - "authorization_signature" to CryptoUtil.eddsaSign(kycKey.raw, priv) + "authorization_sig" to CryptoUtil.eddsaSign(kycKey.raw, priv) "recurrent" to true } }.assertOkJson<SubjectResult>() @@ -181,20 +181,75 @@ class WireTransferApiTest { assertBalance("exchange", "+KUDOS:8") // Switching to non recurrent cancel pending - client.post("/accounts/exchange/taler-wire-transfer/registration") { + client.post("/accounts/exchange/taler-wire-transfer-gateway/registration") { json(valid_req) { "type" to "kyc" "account_pub" to kycKey - "authorization_signature" to CryptoUtil.eddsaSign(kycKey.raw, priv) + "authorization_sig" to CryptoUtil.eddsaSign(kycKey.raw, priv) } }.assertOkJson<SubjectResult>() assertBalance("customer", "-KUDOS:6") assertBalance("exchange", "+KUDOS:6") + + // Check authorization field in incoming history + val (testPriv, testAuth) = EddsaPublicKey.randEdsaKeyPair() + val testKey = EddsaPublicKey.randEdsaKey() + val testSig = CryptoUtil.eddsaSign(testKey.raw, testPriv) + val qr = subjectFmtQrBill(testAuth) + client.post("/accounts/exchange/taler-wire-transfer-gateway/registration") { + json(valid_req) { + "account_pub" to testKey + "authorization_pub" to testAuth + "authorization_sig" to testSig + "recurrent" to true + } + }.assertOkJson<SubjectResult>() + tx("customer", "KUDOS:0.1", "exchange", "Taler MAP:$testAuth") + tx("customer", "KUDOS:0.1", "exchange", "Taler MAP:$testAuth") + tx("customer", "KUDOS:0.1", "exchange", "Taler MAP:$testAuth") + client.post("/accounts/exchange/taler-wire-transfer-gateway/registration") { + json(valid_req) { + "type" to "kyc" + "account_pub" to testKey + "authorization_pub" to testAuth + "authorization_sig" to testSig + "recurrent" to true + } + }.assertOkJson<SubjectResult>() + val otherPub = EddsaPublicKey.randEdsaKey() + val otherSig = CryptoUtil.eddsaSign(otherPub.raw, testPriv) + client.post("/accounts/exchange/taler-wire-transfer-gateway/registration") { + json(valid_req) { + "type" to "reserve" + "account_pub" to otherPub + "authorization_pub" to testAuth + "authorization_sig" to otherSig + "recurrent" to true + } + }.assertOkJson<SubjectResult>() + val lastPub = EddsaPublicKey.randEdsaKey() + tx("customer", "KUDOS:0.1", "exchange", "Taler $lastPub") + tx("customer", "KUDOS:0.1", "exchange", "Taler KYC:$lastPub") + val history = client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?limit=-5") + .assertOkJson<IncomingHistory>().incoming_transactions.map { + when (it) { + is IncomingKycAuthTransaction -> Triple(it.account_pub, it.authorization_pub, it.authorization_sig) + is IncomingReserveTransaction -> Triple(it.reserve_pub, it.authorization_pub, it.authorization_sig) + else -> throw UnsupportedOperationException() + } + } + assertContentEquals(history, listOf( + Triple(lastPub, null, null), + Triple(lastPub, null, null), + Triple(otherPub, testAuth, otherSig), + Triple(testKey, testAuth, testSig), + Triple(testKey, testAuth, testSig) + )) } - // DELETE /accounts/{USERNAME}/taler-wire-transfer/registration + // DELETE /accounts/{USERNAME}/taler-wire-transfer-gateway/registration @Test - fun unregister() = bankSetup { + fun unregistration() = bankSetup { val (priv, pub) = EddsaPublicKey.randEdsaKeyPair() val valid_req = obj { "credit_amount" to "KUDOS:1" @@ -202,60 +257,60 @@ class WireTransferApiTest { "alg" to "ECDSA" "account_pub" to pub "authorization_pub" to pub - "authorization_signature" to CryptoUtil.eddsaSign(pub.raw, priv) + "authorization_sig" to CryptoUtil.eddsaSign(pub.raw, priv) "recurrent" to false } // Unknown - client.delete("/accounts/exchange/taler-wire-transfer/registration") { + client.delete("/accounts/exchange/taler-wire-transfer-gateway/registration") { val now = Instant.now().toString() json { "timestamp" to now "authorization_pub" to pub - "authorization_signature" to CryptoUtil.eddsaSign(now.toByteArray(), priv) + "authorization_sig" to CryptoUtil.eddsaSign(now.toByteArray(), priv) } }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) // Know - client.post("/accounts/exchange/taler-wire-transfer/registration") { + client.post("/accounts/exchange/taler-wire-transfer-gateway/registration") { json(valid_req) }.assertOkJson<SubjectResult>() - client.delete("/accounts/exchange/taler-wire-transfer/registration") { + client.delete("/accounts/exchange/taler-wire-transfer-gateway/registration") { val now = Instant.now().toString() json { "timestamp" to now "authorization_pub" to pub - "authorization_signature" to CryptoUtil.eddsaSign(now.toByteArray(), priv) + "authorization_sig" to CryptoUtil.eddsaSign(now.toByteArray(), priv) } }.assertNoContent() // Idempotent - client.delete("/accounts/exchange/taler-wire-transfer/registration") { + client.delete("/accounts/exchange/taler-wire-transfer-gateway/registration") { val now = Instant.now().toString() json { "timestamp" to now "authorization_pub" to pub - "authorization_signature" to CryptoUtil.eddsaSign(now.toByteArray(), priv) + "authorization_sig" to CryptoUtil.eddsaSign(now.toByteArray(), priv) } }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) // Bad signature - client.delete("/accounts/exchange/taler-wire-transfer/registration") { + client.delete("/accounts/exchange/taler-wire-transfer-gateway/registration") { val now = Instant.now().toString() json { "timestamp" to now "authorization_pub" to pub - "authorization_signature" to CryptoUtil.eddsaSign("lol".toByteArray(), priv) + "authorization_sig" to CryptoUtil.eddsaSign("lol".toByteArray(), priv) } }.assertConflict(TalerErrorCode.BANK_BAD_SIGNATURE) // Old timestamp - client.delete("/accounts/exchange/taler-wire-transfer/registration") { + client.delete("/accounts/exchange/taler-wire-transfer-gateway/registration") { val now = Instant.now().minusSeconds(1000000).toString() json { "timestamp" to now "authorization_pub" to pub - "authorization_signature" to CryptoUtil.eddsaSign(now.toByteArray(), priv) + "authorization_sig" to CryptoUtil.eddsaSign(now.toByteArray(), priv) } }.assertConflict(TalerErrorCode.BANK_OLD_TIMESTAMP) @@ -268,10 +323,10 @@ class WireTransferApiTest { // Pending bounced after deletion val newKey = EddsaPublicKey.randEdsaKey() - client.post("/accounts/exchange/taler-wire-transfer/registration") { + client.post("/accounts/exchange/taler-wire-transfer-gateway/registration") { json(valid_req) { "account_pub" to newKey - "authorization_signature" to CryptoUtil.eddsaSign(newKey.raw, priv) + "authorization_sig" to CryptoUtil.eddsaSign(newKey.raw, priv) "recurrent" to true } }.assertOkJson<SubjectResult>() @@ -280,12 +335,12 @@ class WireTransferApiTest { tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // pending assertBalance("customer", "-KUDOS:3") assertBalance("exchange", "+KUDOS:3") - client.delete("/accounts/exchange/taler-wire-transfer/registration") { + client.delete("/accounts/exchange/taler-wire-transfer-gateway/registration") { val now = Instant.now().toString() json { "timestamp" to now "authorization_pub" to pub - "authorization_signature" to CryptoUtil.eddsaSign(now.toByteArray(), priv) + "authorization_sig" to CryptoUtil.eddsaSign(now.toByteArray(), priv) } }.assertNoContent() assertBalance("customer", "-KUDOS:1") diff --git a/libeufin-bank/src/test/kotlin/bench.kt b/libeufin-bank/src/test/kotlin/bench.kt @@ -97,12 +97,13 @@ class Bench { val uuid = UUID.randomUUID() "$uuid\t$account\t\\\\x$hex\t0\n" }, - "prepared_transfers(type, account_pub, authorization_pub, recurrent, registered_at, bank_transaction_id)" to { + "prepared_transfers(type, account_pub, authorization_pub, authorization_sig, recurrent, registered_at, bank_transaction_id)" to { val type = if (it % 2 == 0) "reserve" else "kyc" val recurrent = if (it % 3 == 0) "true" else "false" val incoming_transaction_id = if (it % 5 == 0) "\\N" else "${it*2}" val hex = accountPubs[it].raw.encodeHex() - "$type\t\\\\x$hex\t\\\\x$hex\t$recurrent\t0\t$incoming_transaction_id\n" + val hex64 = token64.rand().encodeHex() + "$type\t\\\\x$hex\t\\\\x$hex\t\\\\x$hex64\t$recurrent\t0\t$incoming_transaction_id\n" }, "pending_recurrent_incoming_transactions(bank_transaction_id, debtor_account_id, authorization_pub)" to { val hex = accountPubs[it].raw.encodeHex() @@ -393,13 +394,13 @@ class Bench { "alg" to "ECDSA" "account_pub" to pub "authorization_pub" to pub - "authorization_signature" to CryptoUtil.eddsaSign(pub.raw, priv) + "authorization_sig" to CryptoUtil.eddsaSign(pub.raw, priv) "recurrent" to false } - client.post("/accounts/exchange/taler-wire-transfer/registration") { + client.post("/accounts/exchange/taler-wire-transfer-gateway/registration") { json(valid_req) }.assertOkJson<SubjectResult>() - client.post("/accounts/exchange/taler-wire-transfer/registration") { + client.post("/accounts/exchange/taler-wire-transfer-gateway/registration") { json(valid_req) }.assertOkJson<SubjectResult>() } @@ -409,12 +410,12 @@ class Bench { val valid_req = obj { "timestamp" to now "authorization_pub" to pub - "authorization_signature" to CryptoUtil.eddsaSign(now.toByteArray(), priv) + "authorization_sig" to CryptoUtil.eddsaSign(now.toByteArray(), priv) } - client.delete("/accounts/exchange/taler-wire-transfer/registration") { + client.delete("/accounts/exchange/taler-wire-transfer-gateway/registration") { json(valid_req) }.assertNoContent() - client.delete("/accounts/exchange/taler-wire-transfer/registration") { + client.delete("/accounts/exchange/taler-wire-transfer-gateway/registration") { json(valid_req) }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) } diff --git a/libeufin-common/src/main/kotlin/TalerMessage.kt b/libeufin-common/src/main/kotlin/TalerMessage.kt @@ -149,7 +149,9 @@ data class IncomingKycAuthTransaction( override val amount: TalerAmount, override val credit_fee: TalerAmount? = null, override val debit_account: String, - val account_pub: EddsaPublicKey + val account_pub: EddsaPublicKey, + val authorization_pub: EddsaPublicKey? = null, + val authorization_sig: EddsaSignature? = null, ) : IncomingBankTransaction @Serializable @@ -160,7 +162,9 @@ data class IncomingReserveTransaction( override val amount: TalerAmount, override val credit_fee: TalerAmount? = null, override val debit_account: String, - val reserve_pub: EddsaPublicKey + val reserve_pub: EddsaPublicKey, + val authorization_pub: EddsaPublicKey? = null, + val authorization_sig: EddsaSignature? = null, ) : IncomingBankTransaction @Serializable @@ -199,18 +203,18 @@ data class OutgoingTransaction( @Serializable class AccountInfo() -/** Response GET /taler-wire-transfer/config */ +/** Response GET /taler-wire-transfer-gateway/config */ @Serializable data class WireTransferConfig( val currency: String, val supported_formats: List<SubjectFormat> ) { - val name: String = "taler-wire-transfer" + val name: String = "taler-wire-transfer-gateway" val version: String = WIRE_TRANSFER_API_VERSION } -/** Inner response GET /taler-wire-transfer/registration */ +/** Inner response GET /taler-wire-transfer-gateway/registration */ @Serializable sealed interface TransferSubject { @Serializable @@ -259,7 +263,7 @@ data class SubjectRequest( val alg: PublicKeyAlg, val account_pub: EddsaPublicKey, val authorization_pub: EddsaPublicKey, - val authorization_signature: EddsaSignature, + val authorization_sig: EddsaSignature, val recurrent: Boolean ) @@ -273,7 +277,7 @@ data class SubjectResult( data class Unregistration( val timestamp: String, val authorization_pub: EddsaPublicKey, - val authorization_signature: EddsaSignature + val authorization_sig: EddsaSignature ) /** Response GET /taler-revenue/config */ diff --git a/libeufin-common/src/main/kotlin/db/types.kt b/libeufin-common/src/main/kotlin/db/types.kt @@ -63,6 +63,16 @@ inline fun <reified T> ResultSet.getOptObject(column: String): T? { return if (this.wasNull()) null else value as T } +fun ResultSet.getOptKey(column: String): EddsaPublicKey? { + val bytes = this.getBytes(column) + return if (this.wasNull()) null else EddsaPublicKey(bytes) +} + +fun ResultSet.getOptSig(column: String): EddsaSignature? { + val bytes = this.getBytes(column) + return if (this.wasNull()) null else EddsaSignature(bytes) +} + fun ResultSet.getOptLong(name: String): Long? { val nb = getLong(name) if (wasNull()) return null diff --git a/libeufin-nexus/conf/auth.conf b/libeufin-nexus/conf/auth.conf @@ -20,7 +20,7 @@ AUTH_METHOD = basic USERNAME = username PASSWORD = password -[nexus-httpd-wire-transfer-api] +[nexus-httpd-wire-transfer-gateway-api] ENABLED = YES [nexus-httpd-revenue-api] diff --git a/libeufin-nexus/conf/test.conf b/libeufin-nexus/conf/test.conf @@ -19,8 +19,9 @@ ENABLED = YES AUTH_METHOD = bearer TOKEN = secret-token -[nexus-httpd-wire-transfer-api] +[nexus-httpd-wire-transfer-gateway-api] ENABLED = YES +AUTH_METHOD = none [nexus-httpd-revenue-api] ENABLED = YES diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/Config.kt @@ -110,11 +110,8 @@ class NexusEbicsConfig( override val clientPrivateKeysPath = sect.path("client_private_keys_file").require() } -class ApiConfig( - section: TalerConfigSection, - authMethod: AuthMethod? = null -) { - val authMethod = authMethod ?: section.requireAuthMethod() +class ApiConfig(section: TalerConfigSection) { + val authMethod = section.requireAuthMethod() } /** Configuration for libeufin-nexus */ @@ -149,7 +146,7 @@ class NexusConfig internal constructor (val cfg: TalerConfig) { ) val wireGatewayApiCfg = cfg.section("nexus-httpd-wire-gateway-api").apiConf() - val wireTransferApiCfg = cfg.section("nexus-httpd-wire-transfer-api").apiConf(AuthMethod.None) + val wireTransferApiCfg = cfg.section("nexus-httpd-wire-transfer-gateway-api").apiConf() val revenueApiCfg = cfg.section("nexus-httpd-revenue-api").apiConf() val observabilityApiCfg = cfg.section("nexus-httpd-observability-api").apiConf() } @@ -161,10 +158,10 @@ fun NexusConfig.checkCurrency(amount: TalerAmount) { ) } -private fun TalerConfigSection.apiConf(authMethod: AuthMethod? = null): ApiConfig? { +private fun TalerConfigSection.apiConf(): ApiConfig? { val enabled = boolean("enabled").require() return if (enabled) { - return ApiConfig(this, authMethod) + return ApiConfig(this) } else { null } diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/WireTransferApi.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/api/WireTransferApi.kt @@ -40,7 +40,7 @@ import java.time.Instant import java.time.Duration fun Routing.wireTransferApi(db: Database, cfg: NexusConfig) = conditional(cfg.wireTransferApiCfg) { - get("/taler-wire-transfer/config") { + get("/taler-wire-transfer-gateway/config") { call.respond( WireTransferConfig( currency = cfg.currency, @@ -48,10 +48,10 @@ fun Routing.wireTransferApi(db: Database, cfg: NexusConfig) = conditional(cfg.wi ) ) } - post("/taler-wire-transfer/registration") { + post("/taler-wire-transfer-gateway/registration") { val req = call.receive<SubjectRequest>(); - if (!CryptoUtil.checkEdssaSignature(req.account_pub.raw, req.authorization_signature, req.authorization_pub)) + if (!CryptoUtil.checkEdssaSignature(req.account_pub.raw, req.authorization_sig, req.authorization_pub)) throw conflict( "invalid signature", TalerErrorCode.BANK_BAD_SIGNATURE @@ -63,6 +63,7 @@ fun Routing.wireTransferApi(db: Database, cfg: NexusConfig) = conditional(cfg.wi req.type, req.account_pub, req.authorization_pub, + req.authorization_sig, req.recurrent, reference, Instant.now() @@ -88,7 +89,7 @@ fun Routing.wireTransferApi(db: Database, cfg: NexusConfig) = conditional(cfg.wi } } } - delete("/taler-wire-transfer/registration") { + delete("/taler-wire-transfer-gateway/registration") { val req = call.receive<Unregistration>(); val timestamp = Instant.parse(req.timestamp) @@ -99,7 +100,7 @@ fun Routing.wireTransferApi(db: Database, cfg: NexusConfig) = conditional(cfg.wi TalerErrorCode.BANK_OLD_TIMESTAMP ) - if (!CryptoUtil.checkEdssaSignature(req.timestamp.toByteArray(), req.authorization_signature, req.authorization_pub)) + if (!CryptoUtil.checkEdssaSignature(req.timestamp.toByteArray(), req.authorization_sig, req.authorization_pub)) throw conflict( "invalid signature", TalerErrorCode.BANK_BAD_SIGNATURE diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/ExchangeDAO.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/ExchangeDAO.kt @@ -40,6 +40,8 @@ class ExchangeDAO(private val db: Database) { ,debit_payto ,type ,metadata + ,authorization_pub + ,authorization_sig FROM talerable_incoming_transactions JOIN incoming_transactions USING(incoming_transaction_id) WHERE @@ -52,6 +54,8 @@ class ExchangeDAO(private val db: Database) { credit_fee = it.getAmount("credit_fee", db.currency).notZeroOrNull(), debit_account = it.getString("debit_payto"), reserve_pub = EddsaPublicKey(it.getBytes("metadata")), + authorization_pub = it.getOptKey("authorization_pub"), + authorization_sig = it.getOptSig("authorization_sig") ) IncomingType.kyc -> IncomingKycAuthTransaction( row_id = it.getLong("incoming_transaction_id"), @@ -60,6 +64,8 @@ class ExchangeDAO(private val db: Database) { credit_fee = it.getAmount("credit_fee", db.currency).notZeroOrNull(), debit_account = it.getString("debit_payto"), account_pub = EddsaPublicKey(it.getBytes("metadata")), + authorization_pub = it.getOptKey("authorization_pub"), + authorization_sig = it.getOptSig("authorization_sig") ) IncomingType.map -> throw UnsupportedOperationException() } diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/ListDAO.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/ListDAO.kt @@ -45,7 +45,8 @@ class ListDAO(private val db: Database) { ,uetr ,tx_id ,acct_svcr_ref - ,authorization_pub + ,talerable_incoming_transactions.authorization_pub as auth_pub + ,pending_recurrent_incoming_transactions.authorization_pub as pending_pub FROM incoming_transactions AS incoming LEFT JOIN talerable_incoming_transactions USING (incoming_transaction_id) LEFT JOIN bounced_transactions USING (incoming_transaction_id) @@ -57,6 +58,13 @@ class ListDAO(private val db: Database) { ) { all { val type = it.getOptEnum<IncomingType>("type") + val authPub = it.getOptKey("auth_pub") + val pendingPub = it.getOptKey("pending_pub") + val map = if (authPub != null) { + " mapped by ${authPub}" + } else { + "" + } IncomingTxMetadata( id = IncomingId( it.getObject("uetr") as UUID?, @@ -71,11 +79,14 @@ class ListDAO(private val db: Database) { bounced = it.getString("bounced"), talerable = when (type) { null -> { - // TODO map - null + if (pendingPub != null) { + "pending mapped by $pendingPub" + } else { + null + } } - IncomingType.reserve -> "reserve ${EddsaPublicKey(it.getBytes("metadata"))}" - IncomingType.kyc -> "kyc ${EddsaPublicKey(it.getBytes("metadata"))}" + IncomingType.reserve -> "reserve ${EddsaPublicKey(it.getBytes("metadata"))}$map" + IncomingType.kyc -> "kyc ${EddsaPublicKey(it.getBytes("metadata"))}$map" IncomingType.map -> throw UnsupportedOperationException() } ) diff --git a/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/TransferDAO.kt b/libeufin-nexus/src/main/kotlin/tech/libeufin/nexus/db/TransferDAO.kt @@ -37,6 +37,7 @@ class TransferDAO(private val db: Database) { type: TransferType, accountPub: EddsaPublicKey, authPub: EddsaPublicKey, + authSig: EddsaSignature, recurrent: Boolean, referenceNumber: String, timestamp: Instant @@ -46,13 +47,14 @@ class TransferDAO(private val db: Database) { out_subject_reuse ,out_reserve_pub_reuse FROM register_prepared_transfers ( - ?::taler_incoming_type, ?, ?, ?, ?, ? + ?::taler_incoming_type, ?, ?, ?, ?, ?, ? ) """ ) { bind(type) bind(accountPub) bind(authPub) + bind(authSig) bind(recurrent) bind(referenceNumber) bind(timestamp) diff --git a/libeufin-nexus/src/test/kotlin/CliTest.kt b/libeufin-nexus/src/test/kotlin/CliTest.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2023-2025 Taler Systems S.A. + * Copyright (C) 2023, 2024, 2025, 2026 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -79,12 +79,16 @@ class CliTest { // Check with taler transactions talerableOut(db) talerableIn(db) + talerableCompletedIn(db) talerableKycIn(db) + talerablePreparedIn(db) + talerablePreparedCompletedIn(db) check() // Check with incomplete registerIncompleteIn(db) talerableIncompleteIn(db) registerIncompleteOut(db) + talerablePreparedIncompleteIn(db) check() } } \ No newline at end of file diff --git a/libeufin-nexus/src/test/kotlin/DatabaseTest.kt b/libeufin-nexus/src/test/kotlin/DatabaseTest.kt @@ -74,7 +74,7 @@ sealed interface Status { suspend fun Database.checkIn(vararg expected: Status) { val current = this.serializable( """ - SELECT authorization_pub IS NOT NULL, initiated_outgoing_transaction_id IS NOT NULL, debit_payto IS NULL OR subject IS NULL, type, metadata + SELECT pending_recurrent_incoming_transactions.authorization_pub IS NOT NULL, initiated_outgoing_transaction_id IS NOT NULL, debit_payto IS NULL OR subject IS NULL, type, metadata FROM incoming_transactions LEFT JOIN talerable_incoming_transactions USING (incoming_transaction_id) LEFT JOIN pending_recurrent_incoming_transactions USING (incoming_transaction_id) @@ -354,6 +354,7 @@ class IncomingPaymentsTest { val cfg = NexusIngestConfig.default(AccountType.exchange) val firstKey = EddsaPublicKey.randEdsaKey() val authPub = EddsaPublicKey.randEdsaKey() + val sig = EddsaSignature.rand() val referenceNumber = subjectFmtQrBill(authPub) val subject = "test with MAP:$authPub auth pub" @@ -363,6 +364,7 @@ class IncomingPaymentsTest { type = TransferType.reserve, accountPub = firstKey, authPub = authPub, + authSig = sig, referenceNumber = referenceNumber, timestamp = Instant.now(), recurrent = true @@ -403,6 +405,7 @@ class IncomingPaymentsTest { type = TransferType.reserve, accountPub = secondKey, authPub = authPub, + authSig = sig, referenceNumber = referenceNumber, timestamp = Instant.now(), recurrent = true @@ -424,6 +427,7 @@ class IncomingPaymentsTest { type = TransferType.reserve, accountPub = thirdKey, authPub = authPub, + authSig = sig, referenceNumber = referenceNumber, timestamp = Instant.now(), recurrent = true @@ -438,6 +442,7 @@ class IncomingPaymentsTest { val cfg = NexusIngestConfig.default(AccountType.exchange) val firstKey = EddsaPublicKey.randEdsaKey() val authPub = EddsaPublicKey.randEdsaKey() + val sig = EddsaSignature.rand() val referenceNumber = subjectFmtQrBill(authPub) assertEquals( @@ -446,6 +451,7 @@ class IncomingPaymentsTest { type = TransferType.reserve, accountPub = firstKey, authPub = authPub, + authSig = sig, referenceNumber = referenceNumber, timestamp = Instant.now(), recurrent = true @@ -486,6 +492,7 @@ class IncomingPaymentsTest { type = TransferType.reserve, accountPub = secondKey, authPub = authPub, + authSig = sig, referenceNumber = referenceNumber, timestamp = Instant.now(), recurrent = true @@ -507,6 +514,7 @@ class IncomingPaymentsTest { type = TransferType.reserve, accountPub = thirdKey, authPub = authPub, + authSig = sig, referenceNumber = referenceNumber, timestamp = Instant.now(), recurrent = true @@ -960,6 +968,7 @@ class TransferTest { val now = Instant.now() val accountPub = EddsaPublicKey.randEdsaKey() val authPub = EddsaPublicKey.randEdsaKey() + val sig = EddsaSignature.rand() val referenceNumber = subjectFmtQrBill(authPub) // Register @@ -969,6 +978,7 @@ class TransferTest { type = TransferType.reserve, accountPub = accountPub, authPub = authPub, + authSig = sig, referenceNumber = referenceNumber, timestamp = now, recurrent = false @@ -981,6 +991,7 @@ class TransferTest { type = TransferType.reserve, accountPub = accountPub, authPub = authPub, + authSig = sig, referenceNumber = referenceNumber, timestamp = now, recurrent = false @@ -994,6 +1005,7 @@ class TransferTest { type = TransferType.reserve, accountPub = accountPub, authPub = EddsaPublicKey.randEdsaKey(), + authSig = sig, referenceNumber = referenceNumber, timestamp = now, recurrent = false @@ -1007,6 +1019,7 @@ class TransferTest { type = TransferType.reserve, accountPub = accountPub, authPub = authPub, + authSig = sig, referenceNumber = "032847109247158302947510329", timestamp = now, recurrent = false @@ -1020,6 +1033,7 @@ class TransferTest { type = TransferType.reserve, accountPub = accountPub, authPub = EddsaPublicKey.randEdsaKey(), + authSig = sig, referenceNumber = "032847109247158302947510330", timestamp = now, recurrent = true @@ -1033,6 +1047,7 @@ class TransferTest { type = TransferType.reserve, accountPub = accountPub, authPub = authPub, + authSig = sig, referenceNumber = referenceNumber, timestamp = now, recurrent = false @@ -1058,6 +1073,7 @@ class TransferTest { type = TransferType.reserve, accountPub = newKey, authPub = authPub, + authSig = sig, referenceNumber = referenceNumber, timestamp = now, recurrent = true @@ -1100,6 +1116,7 @@ class TransferTest { type = TransferType.kyc, accountPub = kycKey, authPub = authPub, + authSig = sig, referenceNumber = referenceNumber, timestamp = now, recurrent = true @@ -1111,6 +1128,7 @@ class TransferTest { type = TransferType.reserve, accountPub = kycKey, authPub = authPub, + authSig = sig, referenceNumber = referenceNumber, timestamp = now, recurrent = true @@ -1145,6 +1163,7 @@ class TransferTest { type = TransferType.kyc, accountPub = kycKey, authPub = authPub, + authSig = sig, referenceNumber = referenceNumber, timestamp = now, recurrent = false @@ -1164,6 +1183,7 @@ class TransferTest { @Test fun delete() = setup { db, _ -> val authPub = EddsaPublicKey.randEdsaKey() + val sig = EddsaSignature.rand() val referenceNumber = subjectFmtQrBill(authPub) val payto = IbanPayto.rand("Sir Florian") val amount = TalerAmount("KUDOS:2.53") @@ -1178,6 +1198,7 @@ class TransferTest { type = TransferType.reserve, accountPub = authPub, authPub = authPub, + authSig = sig, referenceNumber = referenceNumber, timestamp = Instant.now(), recurrent = false @@ -1202,6 +1223,7 @@ class TransferTest { type = TransferType.reserve, accountPub = authPub, authPub = authPub, + authSig = sig, referenceNumber = referenceNumber, timestamp = Instant.now(), recurrent = false @@ -1224,6 +1246,7 @@ class TransferTest { type = TransferType.kyc, accountPub = authPub, authPub = authPub, + authSig = sig, referenceNumber = referenceNumber, timestamp = Instant.now(), recurrent = true diff --git a/libeufin-nexus/src/test/kotlin/RevenueApiTest.kt b/libeufin-nexus/src/test/kotlin/RevenueApiTest.kt @@ -1,6 +1,6 @@ /* * This file is part of LibEuFin. - * Copyright (C) 2024-2025 Taler Systems S.A. + * Copyright (C) 2024, 2025, 2026 Taler Systems S.A. * LibEuFin is free software; you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as @@ -43,6 +43,8 @@ class RevenueApiTest { // Transactions using clean transfer logic { talerableIn(db) }, { talerableCompletedIn(db) }, + { talerablePreparedIn(db) }, + { talerablePreparedCompletedIn(db) }, // Common credit transactions { registerIn(db) }, @@ -52,11 +54,10 @@ class RevenueApiTest { // Ignore debit transactions { talerableOut(db) }, - // Incomplete taler - { talerableIncompleteIn(db) }, - // Ignore incomplete - { registerIncompleteIn(db) } + { registerIncompleteIn(db) }, + { talerableIncompleteIn(db) }, + { talerablePreparedIncompleteIn(db) }, ) ) } diff --git a/libeufin-nexus/src/test/kotlin/WireGatewayApiTest.kt b/libeufin-nexus/src/test/kotlin/WireGatewayApiTest.kt @@ -255,6 +255,8 @@ class WireGatewayApiTest { // Reserve transactions using raw bank transaction logic { talerableIn(db) }, { talerableCompletedIn(db) }, + { talerablePreparedIn(db) }, + { talerablePreparedCompletedIn(db) }, // KYC transactions using clean add incoming logic { addKyc("CHF:12") }, @@ -277,6 +279,9 @@ class WireGatewayApiTest { // Ignore outgoing transaction { talerableOut(db) }, + + // Ignore prepared incomplete + { talerablePreparedIncompleteIn(db) }, ) ) } @@ -401,15 +406,31 @@ class WireGatewayApiTest { addKyc("CHF:2") talerableIn(db, amount = "CHF:3") talerableKycIn(db, amount = "CHF:4") + talerablePreparedIn(db, amount = "CHF:5") client.getA("/taler-wire-gateway/history/incoming?limit=25").assertOkJson<IncomingHistory> { - assertEquals(4, it.incoming_transactions.size) + assertEquals(5, it.incoming_transactions.size) it.incoming_transactions.forEachIndexed { i, tx -> assertEquals(TalerAmount("CHF:${i+1}"), tx.amount) if (i % 2 == 1) { - assertIs<IncomingKycAuthTransaction>(tx) + val tmp = assertIs<IncomingKycAuthTransaction>(tx) + if (i < 4) { + assertNull(tmp.authorization_pub) + assertNull(tmp.authorization_sig) + } else { + assertNotNull(tmp.authorization_pub) + assertNotNull(tmp.authorization_sig) + } } else { - assertIs<IncomingReserveTransaction>(tx) + val tmp = assertIs<IncomingReserveTransaction>(tx) + if (i < 4) { + assertNull(tmp.authorization_pub) + assertNull(tmp.authorization_sig) + } else { + assertNotNull(tmp.authorization_pub) + assertNotNull(tmp.authorization_sig) + } } + } } } diff --git a/libeufin-nexus/src/test/kotlin/WireTransferApiTest.kt b/libeufin-nexus/src/test/kotlin/WireTransferApiTest.kt @@ -23,21 +23,22 @@ import io.ktor.server.testing.* import org.junit.Test import tech.libeufin.common.* import tech.libeufin.common.crypto.CryptoUtil -import tech.libeufin.nexus.cli.registerOutgoingPayment +import tech.libeufin.nexus.* +import tech.libeufin.nexus.cli.* import tech.libeufin.ebics.randEbicsId import java.time.Instant import kotlin.test.* class WireTransferApiTest { - // GET /taler-wire-transfer/config + // GET /taler-wire-transfer-gateway/config @Test fun config() = serverSetup { - client.get("/taler-wire-transfer/config").assertOkJson<WireTransferConfig>() + client.get("/taler-wire-transfer-gateway/config").assertOkJson<WireTransferConfig>() } - // POST /taler-wire-transfer/registration + // POST /taler-wire-transfer-gateway/registration @Test - fun registration() = serverSetup { + fun registration() = serverSetup { db -> val (priv, pub) = EddsaPublicKey.randEdsaKeyPair() val amount = TalerAmount("KUDOS:55") val valid_req = obj { @@ -46,7 +47,7 @@ class WireTransferApiTest { "alg" to "ECDSA" "account_pub" to pub "authorization_pub" to pub - "authorization_signature" to CryptoUtil.eddsaSign(pub.raw, priv) + "authorization_sig" to CryptoUtil.eddsaSign(pub.raw, priv) "recurrent" to false } @@ -56,90 +57,146 @@ class WireTransferApiTest { ) // Valid - client.post("/taler-wire-transfer/registration") { + client.post("/taler-wire-transfer-gateway/registration") { json(valid_req) }.assertOkJson<SubjectResult> { assertEquals(it.subjects, subjects) } // Idempotent - client.post("/taler-wire-transfer/registration") { + client.post("/taler-wire-transfer-gateway/registration") { json(valid_req) }.assertOkJson<SubjectResult> { assertEquals(it.subjects, subjects) } // Bad signature - client.post("/taler-wire-transfer/registration") { + client.post("/taler-wire-transfer-gateway/registration") { json(valid_req) { - "authorization_signature" to EddsaSignature.rand() + "authorization_sig" to EddsaSignature.rand() } }.assertConflict(TalerErrorCode.BANK_BAD_SIGNATURE) + + // Check authorization field in incoming history + val (testPriv, testAuth) = EddsaPublicKey.randEdsaKeyPair() + val testKey = EddsaPublicKey.randEdsaKey() + val testSig = CryptoUtil.eddsaSign(testKey.raw, testPriv) + val qr = subjectFmtQrBill(testAuth) + client.post("/taler-wire-transfer-gateway/registration") { + json(valid_req) { + "account_pub" to testKey + "authorization_pub" to testAuth + "authorization_sig" to testSig + "recurrent" to true + } + }.assertOkJson<SubjectResult>() + val cfg = NexusIngestConfig.default(AccountType.exchange) + registerIncomingPayment(db, cfg, genInPay(qr)) + registerIncomingPayment(db, cfg, genInPay(qr)) + registerIncomingPayment(db, cfg, genInPay(qr)) + client.post("/taler-wire-transfer-gateway/registration") { + json(valid_req) { + "type" to "kyc" + "account_pub" to testKey + "authorization_pub" to testAuth + "authorization_sig" to testSig + "recurrent" to true + } + }.assertOkJson<SubjectResult>() + val otherPub = EddsaPublicKey.randEdsaKey() + val otherSig = CryptoUtil.eddsaSign(otherPub.raw, testPriv) + client.post("/taler-wire-transfer-gateway/registration") { + json(valid_req) { + "type" to "reserve" + "account_pub" to otherPub + "authorization_pub" to testAuth + "authorization_sig" to otherSig + "recurrent" to true + } + }.assertOkJson<SubjectResult>() + val lastPub = EddsaPublicKey.randEdsaKey() + talerableIn(db, reserve_pub=lastPub) + talerableKycIn(db, account_pub=lastPub) + val history = client.getA("/taler-wire-gateway/history/incoming?limit=-5") + .assertOkJson<IncomingHistory>().incoming_transactions.map { + when (it) { + is IncomingKycAuthTransaction -> Triple(it.account_pub, it.authorization_pub, it.authorization_sig) + is IncomingReserveTransaction -> Triple(it.reserve_pub, it.authorization_pub, it.authorization_sig) + else -> throw UnsupportedOperationException() + } + } + assertContentEquals(history, listOf( + Triple(lastPub, null, null), + Triple(lastPub, null, null), + Triple(otherPub, testAuth, otherSig), + Triple(testKey, testAuth, testSig), + Triple(testKey, testAuth, testSig) + )) } - // DELETE /taler-wire-transfer/registration + // DELETE /taler-wire-transfer-gateway/registration @Test - fun unregister() = serverSetup { + fun unregistration() = serverSetup { val (priv, pub) = EddsaPublicKey.randEdsaKeyPair() // Unknown - client.delete("/taler-wire-transfer/registration") { + client.delete("/taler-wire-transfer-gateway/registration") { val now = Instant.now().toString() json { "timestamp" to now "authorization_pub" to pub - "authorization_signature" to CryptoUtil.eddsaSign(now.toByteArray(), priv) + "authorization_sig" to CryptoUtil.eddsaSign(now.toByteArray(), priv) } }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) // Know - client.post("/taler-wire-transfer/registration") { + client.post("/taler-wire-transfer-gateway/registration") { json { "credit_amount" to "KUDOS:55" "type" to "reserve" "alg" to "ECDSA" "account_pub" to pub "authorization_pub" to pub - "authorization_signature" to CryptoUtil.eddsaSign(pub.raw, priv) + "authorization_sig" to CryptoUtil.eddsaSign(pub.raw, priv) "recurrent" to false } }.assertOkJson<SubjectResult>() - client.delete("/taler-wire-transfer/registration") { + client.delete("/taler-wire-transfer-gateway/registration") { val now = Instant.now().toString() json { "timestamp" to now "authorization_pub" to pub - "authorization_signature" to CryptoUtil.eddsaSign(now.toByteArray(), priv) + "authorization_sig" to CryptoUtil.eddsaSign(now.toByteArray(), priv) } }.assertNoContent() // Idempotent - client.delete("/taler-wire-transfer/registration") { + client.delete("/taler-wire-transfer-gateway/registration") { val now = Instant.now().toString() json { "timestamp" to now "authorization_pub" to pub - "authorization_signature" to CryptoUtil.eddsaSign(now.toByteArray(), priv) + "authorization_sig" to CryptoUtil.eddsaSign(now.toByteArray(), priv) } }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) // Bad signature - client.delete("/taler-wire-transfer/registration") { + client.delete("/taler-wire-transfer-gateway/registration") { val now = Instant.now().toString() json { "timestamp" to now "authorization_pub" to pub - "authorization_signature" to CryptoUtil.eddsaSign("lol".toByteArray(), priv) + "authorization_sig" to CryptoUtil.eddsaSign("lol".toByteArray(), priv) } }.assertConflict(TalerErrorCode.BANK_BAD_SIGNATURE) // Old timestamp - client.delete("/taler-wire-transfer/registration") { + client.delete("/taler-wire-transfer-gateway/registration") { val now = Instant.now().minusSeconds(1000000).toString() json { "timestamp" to now "authorization_pub" to pub - "authorization_signature" to CryptoUtil.eddsaSign(now.toByteArray(), priv) + "authorization_sig" to CryptoUtil.eddsaSign(now.toByteArray(), priv) } }.assertConflict(TalerErrorCode.BANK_OLD_TIMESTAMP) } diff --git a/libeufin-nexus/src/test/kotlin/bench.kt b/libeufin-nexus/src/test/kotlin/bench.kt @@ -70,13 +70,14 @@ class Bench { "initiated_outgoing_transactions(amount, subject, initiation_time, credit_payto, outgoing_transaction_id, end_to_end_id)" to { "(42,0)\tsubject\t0\tcredit_payto\t${it*2}\tE2E_ID$it\n" }, - "prepared_transfers(type, account_pub, authorization_pub, recurrent, reference_number, registered_at, incoming_transaction_id)" to { + "prepared_transfers(type, account_pub, authorization_pub, authorization_sig, recurrent, reference_number, registered_at, incoming_transaction_id)" to { val type = if (it % 2 == 0) "reserve" else "kyc" val recurrent = if (it % 3 == 0) "true" else "false" val incoming_transaction_id = if (it % 5 == 0) "\\N" else "${it*2}" val reference_number = subjectFmtQrBill(accountPubs[it]) - val hex = accountPubs[it].raw.encodeHex() - "$type\t\\\\x$hex\t\\\\x$hex\t$recurrent\t$reference_number\t0\t$incoming_transaction_id\n" + val pub = accountPubs[it].raw.encodeHex() + val sig = token64.rand().encodeHex() + "$type\t\\\\x$pub\t\\\\x$pub\t\\\\x$sig\t$recurrent\t$reference_number\t0\t$incoming_transaction_id\n" }, "pending_recurrent_incoming_transactions(incoming_transaction_id, authorization_pub)" to { val hex = accountPubs[it].raw.encodeHex() @@ -141,6 +142,9 @@ class Bench { measureAction("register_reserve") { talerableIn(db) } + measureAction("register_prepared_reserve") { + talerablePreparedIn(db) + } measureAction("register_kyc") { talerableKycIn(db) } @@ -198,13 +202,13 @@ class Bench { "alg" to "ECDSA" "account_pub" to pub "authorization_pub" to pub - "authorization_signature" to CryptoUtil.eddsaSign(pub.raw, priv) + "authorization_sig" to CryptoUtil.eddsaSign(pub.raw, priv) "recurrent" to false } - client.post("/taler-wire-transfer/registration") { + client.post("/taler-wire-transfer-gateway/registration") { json(valid_req) }.assertOkJson<SubjectResult>() - client.post("/taler-wire-transfer/registration") { + client.post("/taler-wire-transfer-gateway/registration") { json(valid_req) }.assertOkJson<SubjectResult>() } @@ -214,12 +218,12 @@ class Bench { val valid_req = obj { "timestamp" to now "authorization_pub" to pub - "authorization_signature" to CryptoUtil.eddsaSign(now.toByteArray(), priv) + "authorization_sig" to CryptoUtil.eddsaSign(now.toByteArray(), priv) } - client.delete("/taler-wire-transfer/registration") { + client.delete("/taler-wire-transfer-gateway/registration") { json(valid_req) }.assertNoContent() - client.delete("/taler-wire-transfer/registration") { + client.delete("/taler-wire-transfer-gateway/registration") { json(valid_req) }.assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND) } diff --git a/libeufin-nexus/src/test/kotlin/helpers.kt b/libeufin-nexus/src/test/kotlin/helpers.kt @@ -32,9 +32,11 @@ import tech.libeufin.nexus.cli.registerIncomingPayment import tech.libeufin.nexus.cli.registerOutgoingPayment import tech.libeufin.nexus.db.Database import tech.libeufin.nexus.db.InitiatedPayment +import tech.libeufin.nexus.db.TransferDAO.RegistrationResult import tech.libeufin.nexus.iso20022.* import java.time.Instant import kotlin.io.path.Path +import kotlin.test.assertEquals fun conf( conf: String = "test.conf", @@ -152,14 +154,63 @@ suspend fun talerableOut(db: Database, metadata: String? = null) { } /** Register a talerable reserve incoming transaction */ -suspend fun talerableIn(db: Database, amount: String = "CHF:44") { - val reserve_pub = EddsaPublicKey.randEdsaKey() +suspend fun talerableIn( + db: Database, + amount: String = "CHF:44", + reserve_pub: EddsaPublicKey = EddsaPublicKey.randEdsaKey() +) { registerIncomingPayment( db, NexusIngestConfig.default(AccountType.exchange), genInPay("test with $reserve_pub reserve pub", amount) ) } +private suspend fun prepare(db: Database): String { + val pub = EddsaPublicKey.randEdsaKey() + val sig = EddsaSignature.rand() + val referenceNumber = subjectFmtQrBill(pub) + assertEquals( + RegistrationResult.Success, + db.transfer.register( + type = TransferType.reserve, + accountPub = pub, + authPub = pub, + authSig = sig, + referenceNumber = referenceNumber, + timestamp = Instant.now(), + recurrent = false + ) + ) + return referenceNumber +} + +/** Register a talerable reserve prepared incoming transaction */ +suspend fun talerablePreparedIn(db: Database, amount: String = "CHF:44") { + val referenceNumber = prepare(db) + registerIncomingPayment( + db, NexusIngestConfig.default(AccountType.exchange), + genInPay(referenceNumber, amount) + ) +} + +/** Register an incomplete talerable reserve prepared incoming transaction */ +suspend fun talerablePreparedIncompleteIn(db: Database, amount: String = "CHF:44") { + val referenceNumber = prepare(db) + val incomplete = genInPay(referenceNumber).copy(subject = null, debtor = null) + registerIncomingPayment( + db, NexusIngestConfig.default(AccountType.exchange), incomplete + ) +} + +/** Register a completed talerable reserve prepared incoming transaction */ +suspend fun talerablePreparedCompletedIn(db: Database, amount: String = "CHF:44") { + val referenceNumber = prepare(db) + val original = genInPay(referenceNumber, amount) + val incomplete = original.copy(subject = null, debtor = null) + registerIncomingPayment(db, NexusIngestConfig.default(AccountType.exchange), incomplete) + registerIncomingPayment(db, NexusIngestConfig.default(AccountType.exchange), original) +} + /** Register an incomplete talerable reserve incoming transaction */ suspend fun talerableIncompleteIn(db: Database) { val reserve_pub = EddsaPublicKey.randEdsaKey() @@ -177,8 +228,11 @@ suspend fun talerableCompletedIn(db: Database) { } /** Register a talerable KYC incoming transaction */ -suspend fun talerableKycIn(db: Database, amount: String = "CHF:44") { - val account_pub = EddsaPublicKey.randEdsaKey() +suspend fun talerableKycIn( + db: Database, + amount: String = "CHF:44", + account_pub: EddsaPublicKey = EddsaPublicKey.randEdsaKey() +) { registerIncomingPayment( db, NexusIngestConfig.default(AccountType.exchange), genInPay("test with KYC:$account_pub account pub", amount)