taler-typescript-core

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

commit 4a5e6849db14e2b772c97820655c85c91aee4ae5
parent f2abaf6988b6f1da85508770b9d0851f1138b48f
Author: Florian Dold <dold@taler.net>
Date:   Mon, 24 Aug 2026 02:28:42 +0200

taler-util: enforce IBAN registry lengths

Diffstat:
Mpackages/taler-util/src/iban.test.ts | 20+++++++++++++++-----
Mpackages/taler-util/src/iban.ts | 171++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mpackages/taler-util/src/payto.test.ts | 41+++++++++++++++++++++++++++--------------
3 files changed, 151 insertions(+), 81 deletions(-)

diff --git a/packages/taler-util/src/iban.test.ts b/packages/taler-util/src/iban.test.ts @@ -30,7 +30,7 @@ test("iban validation", (t) => { }); test("iban generation", (t) => { - let iban1 = generateIban("DE", 10); + let iban1 = generateIban("DE", 18); assert.ok(validateIban(iban1).type === "valid"); }); @@ -38,9 +38,9 @@ test("iban generation", (t) => { // stripping grouping separators, so a valid IBAN entered with spaces exceeded // 34 characters and was wrongly rejected as TOO_LONG. test("BUG-024 parseIban accepts a valid IBAN with grouping spaces", () => { - // A 31-character IBAN, valid by construction (parseIban does not enforce a - // per-country length, so we build one long enough to matter once grouped). - const iban = constructIban("DE", "123456789012345678901234567"); + // Malta has a 31-character IBAN, long enough for its grouped form to exceed + // the global 34-character maximum while the normalized IBAN remains valid. + const iban = constructIban("MT", "123456789012345678901234567"); assert.equal(iban.length, 31); assert.equal(parseIban(iban).tag, "ok"); // baseline: construct/parse round-trips @@ -49,6 +49,17 @@ test("BUG-024 parseIban accepts a valid IBAN with grouping spaces", () => { assert.equal(parseIban(grouped).tag, "ok"); }); +test("parseIban enforces the country-specific registry length", () => { + const wrongLength = constructIban("DE", "1234567890"); + const result = parseIban(wrongLength); + assert.equal(result.tag, "error"); +}); + +test("parseIban rejects countries that do not participate in IBAN", () => { + const result = parseIban(constructIban("US", "123456789012345678")); + assert.equal(result.tag, "error"); +}); + // BUG-028: the deprecated validateIban removed only the first space // (String.replace with a string pattern) and read the country/check-digit // characters from the original (non-uppercased) input, so lowercase or @@ -67,4 +78,3 @@ test("IBAN check digits must be numeric", (t) => { const r = parseIban("DEAA000000000000000008"); assert.strictEqual(r.tag, "error"); }); - diff --git a/packages/taler-util/src/iban.ts b/packages/taler-util/src/iban.ts @@ -19,13 +19,9 @@ import { Result } from "./result.js"; /** * IBAN validation. * - * Currently only validates the checksum. - * - * It does not validate: - * - Country-specific length - * - Country-specific checksums - * - * The country list is also not complete. + * Validates the ISO country/check-digit shape, the SWIFT registry length and + * the MOD-97-10 checksum. Country-specific BBAN structure checks are left to + * the receiving bank. * * @author Florian Dold <dold@taler.net> * @author sebasjm @@ -48,6 +44,10 @@ export enum ParseIbanError { */ TOO_SHORT, /** + * The IBAN does not have the length assigned to its country. + */ + INVALID_LENGTH, + /** * The IBAN should only have letters and numbers */ INVALID_CHARSET, @@ -196,11 +196,14 @@ export function parseIban( } const countryCode = myIban.substring(0, 2); - const countryInfo = ibanCountryInfoTable[countryCode]; + const expectedLength = ibanCountryLengths[countryCode]; - if (!countryInfo) { + if (!expectedLength) { return Result.error(ParseIbanError.UNSUPPORTED_COUNTRY); } + if (myIban.length !== expectedLength) { + return Result.error(ParseIbanError.INVALID_LENGTH); + } let digits: number[] = []; @@ -233,63 +236,14 @@ export function parseIban( * @returns */ export function validateIban(ibanString: string): IbanValidationResult { - if (ibanString.length < 4) { - return { - type: "invalid", - code: ParseIbanError.TOO_SHORT, - }; - } - if (ibanString.length > 34) { - return { - type: "invalid", - code: ParseIbanError.TOO_LONG, - }; - } - - const myIban = ibanString.toLocaleUpperCase().replace(/\s/g, ""); - const countryCode = myIban.substring(0, 2); - const countryInfo = ibanCountryInfoTable[countryCode]; - - if (!countryInfo) { - return { - type: "invalid", - code: ParseIbanError.UNSUPPORTED_COUNTRY, - }; - } - - let digits: number[] = []; - - for (let i = 4; i < myIban.length; i++) { - const cc = myIban.charCodeAt(i); - if (!appendDigit(digits, cc)) { - return { - type: "invalid", - code: ParseIbanError.INVALID_CHARSET, - }; - } - } - - for (let i = 0; i < 4; i++) { - if (!appendDigit(digits, myIban.charCodeAt(i))) { - return { - type: "invalid", - code: ParseIbanError.INVALID_CHARSET, - }; - } - } - - const rem = mod97(digits); - if (rem === 1) { + const result = parseIban(ibanString); + if (Result.isOk(result)) { return { type: "valid", - normalizedIban: myIban, - }; - } else { - return { - type: "invalid", - code: ParseIbanError.INVALID_CHECKSUM, + normalizedIban: result.value, }; } + return { type: "invalid", code: result.error }; } export function generateIban(countryCode: string, length: number): IbanString { @@ -329,6 +283,99 @@ export function constructIban(countryCode: string, bban: string): IbanString { } /** + * IBAN lengths from the SWIFT IBAN Registry. Presence in this table is the + * source of truth for whether a country currently participates in IBAN. + */ +export const ibanCountryLengths: Readonly<Record<string, number>> = { + AD: 24, + AE: 23, + AL: 28, + AT: 20, + AZ: 28, + BA: 20, + BE: 16, + BG: 22, + BH: 22, + BI: 27, + BJ: 28, + BR: 29, + BY: 28, + CH: 21, + CR: 22, + CY: 28, + CZ: 24, + DE: 22, + DK: 18, + DO: 28, + EE: 20, + EG: 29, + ES: 24, + FI: 18, + FO: 18, + FR: 27, + GB: 22, + GE: 22, + GI: 23, + GL: 18, + GR: 27, + GT: 28, + HR: 21, + HU: 28, + IE: 22, + IL: 23, + IQ: 23, + IS: 26, + IT: 27, + JO: 30, + KW: 30, + KZ: 20, + LB: 28, + LC: 32, + LI: 21, + LT: 20, + LU: 20, + LV: 21, + LY: 25, + MC: 27, + MD: 24, + ME: 22, + MK: 19, + MN: 20, + MR: 27, + MT: 31, + MU: 30, + NL: 18, + NO: 15, + OM: 23, + PK: 24, + PL: 28, + PS: 29, + PT: 25, + QA: 29, + RO: 24, + RS: 22, + RU: 33, + SA: 24, + SC: 31, + SD: 18, + SE: 24, + SI: 19, + SK: 24, + SM: 27, + SO: 23, + ST: 25, + SV: 28, + TL: 23, + TN: 24, + TR: 26, + UA: 29, + VA: 22, + VG: 24, + XK: 20, + YE: 30, +}; + +/** * Incomplete list, see https://www.swift.com/resource/iban-registry-pdf */ export const ibanCountryInfoTable: Record<string, IbanCountryInfo> = { diff --git a/packages/taler-util/src/payto.test.ts b/packages/taler-util/src/payto.test.ts @@ -67,13 +67,13 @@ test("basic x-taler-bank payto string", (t) => { }); test("parsing payto and stringify again on normalized strings are unchanged", (t) => { - const payto1 = "payto://iban/DE1231231231?reciever-name=John%20Doe"; + const payto1 = "payto://iban/DE89370400440532013000?reciever-name=John%20Doe"; assert.strictEqual( Paytos.toFullString(Result.unpack(Paytos.fromString(payto1))), payto1 as Paytos.FullPaytoString, ); - const normalized = "payto://iban/DE1231231231"; + const normalized = "payto://iban/DE89370400440532013000"; assert.strictEqual( Paytos.toNormalizedString(Result.unpack(Paytos.fromString(payto1))), normalized as Paytos.NormalizedPaytoString, @@ -81,10 +81,10 @@ test("parsing payto and stringify again on normalized strings are unchanged", (t }); test("parsing payto and stringify again converts to the normal form", (t) => { const fullPayto_not_normalized = - "payto://iban/de1231231231?reciever-name=John%20Doe"; + "payto://iban/de89370400440532013000?reciever-name=John%20Doe"; // after normalization the country code is uppercased const fullPayto_normalized = - "payto://iban/DE1231231231?reciever-name=John%20Doe" as Paytos.FullPaytoString; + "payto://iban/DE89370400440532013000?reciever-name=John%20Doe" as Paytos.FullPaytoString; assert.strictEqual( Paytos.toFullString( Result.unpack(Paytos.fromString(fullPayto_not_normalized)), @@ -92,9 +92,9 @@ test("parsing payto and stringify again converts to the normal form", (t) => { fullPayto_normalized, ); - const normalized_lowercase = "payto://iban/DE1231231231"; + const normalized_lowercase = "payto://iban/DE89370400440532013000"; const normalized_uppercase = - "payto://iban/DE1231231231" as Paytos.NormalizedPaytoString; + "payto://iban/DE89370400440532013000" as Paytos.NormalizedPaytoString; assert.strictEqual( Paytos.toNormalizedString( Result.unpack(Paytos.fromString(normalized_lowercase)), @@ -104,7 +104,7 @@ test("parsing payto and stringify again converts to the normal form", (t) => { }); test("parsing payto with % carh", (t) => { const payto1 = - "payto://iban/DE7763544441436?receiver-name=Test%20123%2B-%24%25%5E%3Cem%3Ehi%3C%2Fem%3E" as Paytos.FullPaytoString; + "payto://iban/DE89370400440532013000?receiver-name=Test%20123%2B-%24%25%5E%3Cem%3Ehi%3C%2Fem%3E" as Paytos.FullPaytoString; assert.strictEqual( Paytos.toFullString(Result.unpack(Paytos.fromString(payto1))), @@ -114,14 +114,14 @@ test("parsing payto with % carh", (t) => { test("adding payto query params via toFullString", (t) => { const payto1 = - "payto://iban/DE1231231231?receiver-name=John%20Doe" as Paytos.FullPaytoString; + "payto://iban/DE89370400440532013000?receiver-name=John%20Doe" as Paytos.FullPaytoString; const p = Result.unpack(Paytos.fromString(payto1)); p.params["foo"] = "42"; assert.deepStrictEqual( Paytos.toFullString(p), - "payto://iban/DE1231231231?receiver-name=John%20Doe&foo=42", + "payto://iban/DE89370400440532013000?receiver-name=John%20Doe&foo=42", ); }); @@ -381,14 +381,21 @@ test("toFullString keeps the query parameters", (t) => { }); test("Paytos helper functions extract fields and construct URIs correctly", () => { - const ibanPayto = Paytos.parsePaytoUri("payto://iban/DE75512108001245126199?receiver-name=Alice&receiver-town=Berlin&receiver-postal-code=10115"); + const ibanPayto = Paytos.parsePaytoUri( + "payto://iban/DE75512108001245126199?receiver-name=Alice&receiver-town=Berlin&receiver-postal-code=10115", + ); assert.ok(ibanPayto); assert.strictEqual(Paytos.getAccountHolder(ibanPayto), "Alice"); assert.strictEqual(Paytos.getReceiverTown(ibanPayto), "Berlin"); assert.strictEqual(Paytos.getReceiverPostalCode(ibanPayto), "10115"); - assert.strictEqual(Paytos.getAccountNumber(ibanPayto), "DE75 5121 0800 1245 1261 99"); + assert.strictEqual( + Paytos.getAccountNumber(ibanPayto), + "DE75 5121 0800 1245 1261 99", + ); - const bankPayto = Paytos.parsePaytoUri("payto://x-taler-bank/bank.example.com/bob?receiver-name=Bob"); + const bankPayto = Paytos.parsePaytoUri( + "payto://x-taler-bank/bank.example.com/bob?receiver-name=Bob", + ); assert.ok(bankPayto); assert.strictEqual(Paytos.getAccountHolder(bankPayto), "Bob"); assert.strictEqual(Paytos.getBankHost(bankPayto), "bank.example.com"); @@ -399,7 +406,10 @@ test("Paytos helper functions extract fields and construct URIs correctly", () = iban: "DE75512108001245126199", accountHolder: "Charlie", }); - assert.strictEqual(constructedIban, "payto://iban/DE75512108001245126199?receiver-name=Charlie"); + assert.strictEqual( + constructedIban, + "payto://iban/DE75512108001245126199?receiver-name=Charlie", + ); const constructedBank = Paytos.constructPayto({ targetType: "x-taler-bank", @@ -407,5 +417,8 @@ test("Paytos helper functions extract fields and construct URIs correctly", () = accountName: "dave", accountHolder: "Dave", }); - assert.strictEqual(constructedBank, "payto://x-taler-bank/bank.example.com/dave?receiver-name=Dave"); + assert.strictEqual( + constructedBank, + "payto://x-taler-bank/bank.example.com/dave?receiver-name=Dave", + ); });