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:
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",
+ );
});