diff --git a/packages/taler-util/src/iban.test.ts b/packages/taler-util/src/iban.test.ts new file mode 100644 index 000000000..69fb2dd75 --- /dev/null +++ b/packages/taler-util/src/iban.test.ts @@ -0,0 +1,32 @@ +/* + This file is part of GNU Taler + (C) 2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +import test from "ava"; +import { generateIban, validateIban } from "./iban.js"; + +test("iban validation", (t) => { + t.assert(validateIban("foo").type === "invalid"); + t.assert(validateIban("NL71RABO9996666778").type === "valid"); + t.assert(validateIban("NL71RABO9996666779").type === "invalid"); +}); + + + +test("iban generation", (t) => { + let iban1 = generateIban("DE", 10); + console.log("generated IBAN", iban1); + t.assert(validateIban(iban1).type === "valid"); +}); diff --git a/packages/taler-util/src/iban.ts b/packages/taler-util/src/iban.ts new file mode 100644 index 000000000..3a40e45a4 --- /dev/null +++ b/packages/taler-util/src/iban.ts @@ -0,0 +1,296 @@ +/* + This file is part of GNU Taler + (C) 2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see + */ + +/** + * IBAN validation. + * + * Currently only validates the checksum. + * + * It does not validate: + * - Country-specific length + * - Country-specific checksums + * + * The country list is also not complete. + * + * @author Florian Dold + */ + +export type IbanValidationResult = + | { type: "invalid" } + | { + type: "valid"; + normalizedIban: string; + }; + +export interface IbanCountryInfo { + name: string; + isSepa?: boolean; + length?: number; +} + +/** + * Incomplete list, see https://www.swift.com/resource/iban-registry-pdf + */ +export const ibanCountryInfoTable: Record = { + AE: { name: "U.A.E." }, + AF: { name: "Afghanistan" }, + AL: { name: "Albania" }, + AM: { name: "Armenia" }, + AN: { name: "Netherlands Antilles" }, + AR: { name: "Argentina" }, + AT: { name: "Austria" }, + AU: { name: "Australia" }, + AZ: { name: "Azerbaijan" }, + BA: { name: "Bosnia and Herzegovina" }, + BD: { name: "Bangladesh" }, + BE: { name: "Belgium" }, + BG: { name: "Bulgaria" }, + BH: { name: "Bahrain" }, + BN: { name: "Brunei Darussalam" }, + BO: { name: "Bolivia" }, + BR: { name: "Brazil" }, + BT: { name: "Bhutan" }, + BY: { name: "Belarus" }, + BZ: { name: "Belize" }, + CA: { name: "Canada" }, + CG: { name: "Congo" }, + CH: { name: "Switzerland" }, + CI: { name: "Cote d'Ivoire" }, + CL: { name: "Chile" }, + CM: { name: "Cameroon" }, + CN: { name: "People's Republic of China" }, + CO: { name: "Colombia" }, + CR: { name: "Costa Rica" }, + CS: { name: "Serbia and Montenegro" }, + CZ: { name: "Czech Republic" }, + DE: { name: "Germany" }, + DK: { name: "Denmark" }, + DO: { name: "Dominican Republic" }, + DZ: { name: "Algeria" }, + EC: { name: "Ecuador" }, + EE: { name: "Estonia" }, + EG: { name: "Egypt" }, + ER: { name: "Eritrea" }, + ES: { name: "Spain" }, + ET: { name: "Ethiopia" }, + FI: { name: "Finland" }, + FO: { name: "Faroe Islands" }, + FR: { name: "France" }, + GB: { name: "United Kingdom" }, + GD: { name: "Caribbean" }, + GE: { name: "Georgia" }, + GL: { name: "Greenland" }, + GR: { name: "Greece" }, + GT: { name: "Guatemala" }, + HK: { name: "Hong Kong S.A.R." }, + HN: { name: "Honduras" }, + HR: { name: "Croatia" }, + HT: { name: "Haiti" }, + HU: { name: "Hungary" }, + ID: { name: "Indonesia" }, + IE: { name: "Ireland" }, + IL: { name: "Israel" }, + IN: { name: "India" }, + IQ: { name: "Iraq" }, + IR: { name: "Iran" }, + IS: { name: "Iceland" }, + IT: { name: "Italy" }, + JM: { name: "Jamaica" }, + JO: { name: "Jordan" }, + JP: { name: "Japan" }, + KE: { name: "Kenya" }, + KG: { name: "Kyrgyzstan" }, + KH: { name: "Cambodia" }, + KR: { name: "South Korea" }, + KW: { name: "Kuwait" }, + KZ: { name: "Kazakhstan" }, + LA: { name: "Laos" }, + LB: { name: "Lebanon" }, + LI: { name: "Liechtenstein" }, + LK: { name: "Sri Lanka" }, + LT: { name: "Lithuania" }, + LU: { name: "Luxembourg" }, + LV: { name: "Latvia" }, + LY: { name: "Libya" }, + MA: { name: "Morocco" }, + MC: { name: "Principality of Monaco" }, + MD: { name: "Moldava" }, + ME: { name: "Montenegro" }, + MK: { name: "Former Yugoslav Republic of Macedonia" }, + ML: { name: "Mali" }, + MM: { name: "Myanmar" }, + MN: { name: "Mongolia" }, + MO: { name: "Macau S.A.R." }, + MT: { name: "Malta" }, + MV: { name: "Maldives" }, + MX: { name: "Mexico" }, + MY: { name: "Malaysia" }, + NG: { name: "Nigeria" }, + NI: { name: "Nicaragua" }, + NL: { name: "Netherlands" }, + NO: { name: "Norway" }, + NP: { name: "Nepal" }, + NZ: { name: "New Zealand" }, + OM: { name: "Oman" }, + PA: { name: "Panama" }, + PE: { name: "Peru" }, + PH: { name: "Philippines" }, + PK: { name: "Islamic Republic of Pakistan" }, + PL: { name: "Poland" }, + PR: { name: "Puerto Rico" }, + PT: { name: "Portugal" }, + PY: { name: "Paraguay" }, + QA: { name: "Qatar" }, + RE: { name: "Reunion" }, + RO: { name: "Romania" }, + RS: { name: "Serbia" }, + RU: { name: "Russia" }, + RW: { name: "Rwanda" }, + SA: { name: "Saudi Arabia" }, + SE: { name: "Sweden" }, + SG: { name: "Singapore" }, + SI: { name: "Slovenia" }, + SK: { name: "Slovak" }, + SN: { name: "Senegal" }, + SO: { name: "Somalia" }, + SR: { name: "Suriname" }, + SV: { name: "El Salvador" }, + SY: { name: "Syria" }, + TH: { name: "Thailand" }, + TJ: { name: "Tajikistan" }, + TM: { name: "Turkmenistan" }, + TN: { name: "Tunisia" }, + TR: { name: "Turkey" }, + TT: { name: "Trinidad and Tobago" }, + TW: { name: "Taiwan" }, + TZ: { name: "Tanzania" }, + UA: { name: "Ukraine" }, + US: { name: "United States" }, + UY: { name: "Uruguay" }, + VA: { name: "Vatican" }, + VE: { name: "Venezuela" }, + VN: { name: "Viet Nam" }, + YE: { name: "Yemen" }, + ZA: { name: "South Africa" }, + ZW: { name: "Zimbabwe" }, +}; + +let ccZero = "0".charCodeAt(0); +let ccNine = "9".charCodeAt(0); +let ccA = "A".charCodeAt(0); +let ccZ = "Z".charCodeAt(0); + +/** + * Append a IBAN digit(s) based on a char code. + */ +function appendDigit(digits: number[], cc: number): boolean { + if (cc >= ccZero && cc <= ccNine) { + digits.push(cc - ccZero); + } else if (cc >= ccA && cc <= ccZ) { + const n = cc - ccA + 10; + digits.push(Math.floor(n / 10) % 10); + digits.push(n % 10); + } else { + return false; + } + return true; +} + +/** + * Compute MOD-97-10 as per ISO/IEC 7064:2003. + */ +function mod97(digits: number[]): number { + let i = 0; + let modAccum = 0; + while (i < digits.length) { + let n = 0; + while (n < 9 && i < digits.length) { + modAccum = modAccum * 10 + digits[i]; + i++; + n++; + } + modAccum = modAccum % 97; + } + return modAccum; +} + +export function validateIban(ibanString: string): IbanValidationResult { + let myIban = ibanString.toLocaleUpperCase().replace(" ", ""); + let countryCode = myIban.substring(0, 2); + let countryInfo = ibanCountryInfoTable[countryCode]; + + if (!countryInfo) { + return { + type: "invalid", + }; + } + + let digits: number[] = []; + + for (let i = 4; i < myIban.length; i++) { + const cc = myIban.charCodeAt(i); + if (!appendDigit(digits, cc)) { + return { + type: "invalid", + }; + } + } + + for (let i = 0; i < 4; i++) { + if (!appendDigit(digits, ibanString.charCodeAt(i))) { + return { + type: "invalid", + }; + } + } + + const rem = mod97(digits); + if (rem === 1) { + return { + type: "valid", + normalizedIban: myIban, + }; + } else { + return { + type: "invalid", + }; + } +} + +export function generateIban(countryCode: string, length: number): string { + let ibanSuffix = ""; + let digits: number[] = []; + + for (let i = 0; i < length; i++) { + const cc = ccZero + (Math.floor(Math.random() * 100) % 10); + appendDigit(digits, cc) + ibanSuffix += String.fromCharCode(cc); + } + + appendDigit(digits, countryCode.charCodeAt(0)); + appendDigit(digits, countryCode.charCodeAt(1)); + + // Try using "00" as check digits + appendDigit(digits, ccZero); + appendDigit(digits, ccZero); + + const requiredChecksum = 98 - mod97(digits); + + const checkDigit1 = Math.floor(requiredChecksum / 10) % 10; + const checkDigit2 = requiredChecksum % 10; + + return countryCode + checkDigit1 + checkDigit2 + ibanSuffix; +} diff --git a/packages/taler-util/src/index.ts b/packages/taler-util/src/index.ts index cf4f545a4..1c882a82b 100644 --- a/packages/taler-util/src/index.ts +++ b/packages/taler-util/src/index.ts @@ -37,3 +37,4 @@ export * from "./contract-terms.js"; export * from "./base64.js"; export * from "./merchant-api-types.js"; export * from "./errors.js"; +export * from "./iban.js"; diff --git a/packages/taler-util/src/wallet-types.ts b/packages/taler-util/src/wallet-types.ts index 338124d08..b08b02ca3 100644 --- a/packages/taler-util/src/wallet-types.ts +++ b/packages/taler-util/src/wallet-types.ts @@ -1663,11 +1663,10 @@ export interface ResumeTransactionRequest { transactionId: string; } -export const codecForResumeTransaction = - (): Codec => - buildCodecForObject() - .property("transactionId", codecForString()) - .build("ResumeTransactionRequest"); +export const codecForResumeTransaction = (): Codec => + buildCodecForObject() + .property("transactionId", codecForString()) + .build("ResumeTransactionRequest"); export interface AbortTransactionRequest { transactionId: string; @@ -2257,3 +2256,21 @@ export interface PayPeerInsufficientBalanceDetails { }; }; } + +export interface ValidateIbanRequest { + iban: string; +} + +export const codecForValidateIbanRequest = (): Codec => + buildCodecForObject() + .property("iban", codecForString()) + .build("ValidateIbanRequest"); + +export interface ValidateIbanResponse { + valid: boolean; +} + +export const codecForValidateIbanResponse = (): Codec => + buildCodecForObject() + .property("valid", codecForBoolean()) + .build("ValidateIbanResponse"); diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts index 84bad09fe..0b1968857 100644 --- a/packages/taler-wallet-core/src/wallet-api-types.ts +++ b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -110,6 +110,8 @@ import { WithdrawFakebankRequest, WithdrawTestBalanceRequest, WithdrawUriInfoResponse, + ValidateIbanRequest, + ValidateIbanResponse, } from "@gnu-taler/taler-util"; import { WalletContractData } from "./db.js"; import { @@ -197,6 +199,7 @@ export enum WalletApiOperation { Recycle = "recycle", SetDevMode = "setDevMode", ApplyDevExperiment = "applyDevExperiment", + ValidateIban = "validateIban", } // group: Initialization @@ -683,6 +686,14 @@ export type ConfirmPeerPullDebitOp = { response: EmptyObject; }; +// group: Data Validation + +export type ValidateIbanOp = { + op: WalletApiOperation.ValidateIban; + request: ValidateIbanRequest; + response: ValidateIbanResponse; +}; + // group: Database Management /** @@ -937,6 +948,7 @@ export type WalletOperations = { [WalletApiOperation.Recycle]: RecycleOp; [WalletApiOperation.ApplyDevExperiment]: ApplyDevExperimentOp; [WalletApiOperation.SetDevMode]: SetDevModeOp; + [WalletApiOperation.ValidateIban]: ValidateIbanOp; }; export type WalletCoreRequestType< diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index 6197f000e..363214e7b 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -109,6 +109,9 @@ import { WalletNotification, codecForSuspendTransaction, codecForResumeTransaction, + validateIban, + codecForValidateIbanRequest, + ValidateIbanResponse, } from "@gnu-taler/taler-util"; import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js"; import { @@ -1043,6 +1046,14 @@ async function dispatchRequestInternal( await runIntegrationTest(ws, req); return {}; } + case WalletApiOperation.ValidateIban: { + const req = codecForValidateIbanRequest().decode(payload); + const valRes = validateIban(req.iban); + const resp: ValidateIbanResponse = { + valid: valRes.type === "valid", + }; + return resp; + } case WalletApiOperation.TestPay: { const req = codecForTestPayArgs().decode(payload); return await testPay(ws, req);