@coderoftime/fints 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (215) hide show
  1. package/CHANGELOG.md +377 -0
  2. package/README.md +31 -23
  3. package/dist/client.d.ts +3 -2
  4. package/dist/client.d.ts.map +1 -1
  5. package/dist/client.js +52 -9
  6. package/dist/client.js.map +1 -1
  7. package/dist/core/account.d.ts +15 -4
  8. package/dist/core/account.d.ts.map +1 -1
  9. package/dist/core/account.js +84 -36
  10. package/dist/core/account.js.map +1 -1
  11. package/dist/core/amount.d.ts +13 -3
  12. package/dist/core/amount.d.ts.map +1 -1
  13. package/dist/core/amount.js +42 -8
  14. package/dist/core/amount.js.map +1 -1
  15. package/dist/core/balance.d.ts +3 -0
  16. package/dist/core/balance.d.ts.map +1 -1
  17. package/dist/core/balance.js +5 -0
  18. package/dist/core/balance.js.map +1 -1
  19. package/dist/core/bank-info.d.ts +20 -3
  20. package/dist/core/bank-info.d.ts.map +1 -1
  21. package/dist/core/bank-info.js +25 -4
  22. package/dist/core/bank-info.js.map +1 -1
  23. package/dist/core/client.d.ts +42 -15
  24. package/dist/core/client.d.ts.map +1 -1
  25. package/dist/core/config.d.ts +15 -7
  26. package/dist/core/config.d.ts.map +1 -1
  27. package/dist/core/config.js +29 -14
  28. package/dist/core/config.js.map +1 -1
  29. package/dist/core/errors.d.ts +17 -5
  30. package/dist/core/errors.d.ts.map +1 -1
  31. package/dist/core/errors.js +25 -7
  32. package/dist/core/errors.js.map +1 -1
  33. package/dist/core/feedback.d.ts +7 -0
  34. package/dist/core/feedback.d.ts.map +1 -1
  35. package/dist/core/feedback.js +10 -1
  36. package/dist/core/feedback.js.map +1 -1
  37. package/dist/core/transaction.d.ts +5 -0
  38. package/dist/core/transaction.d.ts.map +1 -1
  39. package/dist/core/transaction.js +12 -0
  40. package/dist/core/transaction.js.map +1 -1
  41. package/dist/core/transport.d.ts +22 -7
  42. package/dist/core/transport.d.ts.map +1 -1
  43. package/dist/core/transport.js +51 -11
  44. package/dist/core/transport.js.map +1 -1
  45. package/dist/formats/camt.d.ts +7 -4
  46. package/dist/formats/camt.d.ts.map +1 -1
  47. package/dist/formats/camt.js +53 -54
  48. package/dist/formats/camt.js.map +1 -1
  49. package/dist/formats/mt940.d.ts +18 -6
  50. package/dist/formats/mt940.d.ts.map +1 -1
  51. package/dist/formats/mt940.js +83 -66
  52. package/dist/formats/mt940.js.map +1 -1
  53. package/dist/formats/readers.d.ts.map +1 -1
  54. package/dist/formats/readers.js +6 -2
  55. package/dist/formats/readers.js.map +1 -1
  56. package/dist/formats/sepa-purpose.d.ts +7 -4
  57. package/dist/formats/sepa-purpose.d.ts.map +1 -1
  58. package/dist/formats/sepa-purpose.js +18 -9
  59. package/dist/formats/sepa-purpose.js.map +1 -1
  60. package/dist/session/dialog-runner.d.ts +5 -2
  61. package/dist/session/dialog-runner.d.ts.map +1 -1
  62. package/dist/session/dialog-runner.js +23 -7
  63. package/dist/session/dialog-runner.js.map +1 -1
  64. package/dist/session/login-guard.d.ts +4 -4
  65. package/dist/session/login-guard.d.ts.map +1 -1
  66. package/dist/session/login-guard.js +29 -14
  67. package/dist/session/login-guard.js.map +1 -1
  68. package/dist/session/session-setup.d.ts +14 -5
  69. package/dist/session/session-setup.d.ts.map +1 -1
  70. package/dist/session/session-setup.js +30 -9
  71. package/dist/session/session-setup.js.map +1 -1
  72. package/dist/session/session-state.d.ts +4 -3
  73. package/dist/session/session-state.d.ts.map +1 -1
  74. package/dist/session/session-state.js +28 -9
  75. package/dist/session/session-state.js.map +1 -1
  76. package/dist/session/signed-dialog.d.ts +19 -13
  77. package/dist/session/signed-dialog.d.ts.map +1 -1
  78. package/dist/session/signed-dialog.js +45 -30
  79. package/dist/session/signed-dialog.js.map +1 -1
  80. package/dist/session/user-prompts.d.ts +6 -1
  81. package/dist/session/user-prompts.d.ts.map +1 -1
  82. package/dist/session/user-prompts.js +19 -4
  83. package/dist/session/user-prompts.js.map +1 -1
  84. package/dist/tan/challenge.d.ts +15 -13
  85. package/dist/tan/challenge.d.ts.map +1 -1
  86. package/dist/tan/challenge.js +32 -26
  87. package/dist/tan/challenge.js.map +1 -1
  88. package/dist/tan/flicker.d.ts +5 -1
  89. package/dist/tan/flicker.d.ts.map +1 -1
  90. package/dist/tan/flicker.js +42 -30
  91. package/dist/tan/flicker.js.map +1 -1
  92. package/dist/tan/tan-image.d.ts +4 -1
  93. package/dist/tan/tan-image.d.ts.map +1 -1
  94. package/dist/tan/tan-image.js +8 -2
  95. package/dist/tan/tan-image.js.map +1 -1
  96. package/dist/tan/tan-models.d.ts +9 -1
  97. package/dist/tan/tan-models.d.ts.map +1 -1
  98. package/dist/tan/tan-models.js +14 -2
  99. package/dist/tan/tan-models.js.map +1 -1
  100. package/dist/util/arguments.d.ts +13 -0
  101. package/dist/util/arguments.d.ts.map +1 -0
  102. package/dist/util/arguments.js +40 -0
  103. package/dist/util/arguments.js.map +1 -0
  104. package/dist/util/async.d.ts +4 -1
  105. package/dist/util/async.d.ts.map +1 -1
  106. package/dist/util/async.js +6 -3
  107. package/dist/util/async.js.map +1 -1
  108. package/dist/util/bytes.d.ts +1 -1
  109. package/dist/util/bytes.d.ts.map +1 -1
  110. package/dist/util/bytes.js +3 -3
  111. package/dist/util/bytes.js.map +1 -1
  112. package/dist/util/dates.d.ts +3 -10
  113. package/dist/util/dates.d.ts.map +1 -1
  114. package/dist/util/dates.js +49 -39
  115. package/dist/util/dates.js.map +1 -1
  116. package/dist/util/errors.d.ts +5 -3
  117. package/dist/util/errors.d.ts.map +1 -1
  118. package/dist/util/errors.js +8 -4
  119. package/dist/util/errors.js.map +1 -1
  120. package/dist/util/immutable-map.d.ts +5 -2
  121. package/dist/util/immutable-map.d.ts.map +1 -1
  122. package/dist/util/immutable-map.js +15 -12
  123. package/dist/util/immutable-map.js.map +1 -1
  124. package/dist/util/inspect.d.ts +11 -0
  125. package/dist/util/inspect.d.ts.map +1 -0
  126. package/dist/util/inspect.js +14 -0
  127. package/dist/util/inspect.js.map +1 -0
  128. package/dist/util/text.d.ts +18 -2
  129. package/dist/util/text.d.ts.map +1 -1
  130. package/dist/util/text.js +50 -5
  131. package/dist/util/text.js.map +1 -1
  132. package/dist/util/xml.d.ts +6 -4
  133. package/dist/util/xml.d.ts.map +1 -1
  134. package/dist/util/xml.js +106 -44
  135. package/dist/util/xml.js.map +1 -1
  136. package/dist/v3/bank-connection.d.ts +23 -3
  137. package/dist/v3/bank-connection.d.ts.map +1 -1
  138. package/dist/v3/bank-connection.js +66 -6
  139. package/dist/v3/bank-connection.js.map +1 -1
  140. package/dist/v3/dialog.d.ts +6 -2
  141. package/dist/v3/dialog.d.ts.map +1 -1
  142. package/dist/v3/dialog.js +20 -9
  143. package/dist/v3/dialog.js.map +1 -1
  144. package/dist/v3/message.d.ts +18 -10
  145. package/dist/v3/message.d.ts.map +1 -1
  146. package/dist/v3/message.js +92 -75
  147. package/dist/v3/message.js.map +1 -1
  148. package/dist/v3/parameter-store.d.ts +43 -7
  149. package/dist/v3/parameter-store.d.ts.map +1 -1
  150. package/dist/v3/parameter-store.js +69 -15
  151. package/dist/v3/parameter-store.js.map +1 -1
  152. package/dist/v3/parameters.d.ts +12 -11
  153. package/dist/v3/parameters.d.ts.map +1 -1
  154. package/dist/v3/parameters.js +57 -35
  155. package/dist/v3/parameters.js.map +1 -1
  156. package/dist/v3/protocol.d.ts +16 -9
  157. package/dist/v3/protocol.d.ts.map +1 -1
  158. package/dist/v3/protocol.js +57 -26
  159. package/dist/v3/protocol.js.map +1 -1
  160. package/dist/v3/queries.d.ts +2 -2
  161. package/dist/v3/queries.d.ts.map +1 -1
  162. package/dist/v3/queries.js +24 -16
  163. package/dist/v3/queries.js.map +1 -1
  164. package/dist/v3/segments.d.ts +18 -11
  165. package/dist/v3/segments.d.ts.map +1 -1
  166. package/dist/v3/segments.js +30 -21
  167. package/dist/v3/segments.js.map +1 -1
  168. package/dist/v3/syntax.d.ts +15 -11
  169. package/dist/v3/syntax.d.ts.map +1 -1
  170. package/dist/v3/syntax.js +30 -15
  171. package/dist/v3/syntax.js.map +1 -1
  172. package/package.json +1 -1
  173. package/src/client.ts +58 -17
  174. package/src/core/account.ts +91 -35
  175. package/src/core/amount.ts +42 -9
  176. package/src/core/balance.ts +6 -0
  177. package/src/core/bank-info.ts +28 -4
  178. package/src/core/client.ts +42 -15
  179. package/src/core/config.ts +37 -18
  180. package/src/core/errors.ts +27 -7
  181. package/src/core/feedback.ts +12 -1
  182. package/src/core/transaction.ts +13 -0
  183. package/src/core/transport.ts +62 -16
  184. package/src/formats/camt.ts +62 -51
  185. package/src/formats/mt940.ts +114 -78
  186. package/src/formats/readers.ts +6 -2
  187. package/src/formats/sepa-purpose.ts +17 -9
  188. package/src/session/dialog-runner.ts +34 -7
  189. package/src/session/login-guard.ts +29 -13
  190. package/src/session/session-setup.ts +27 -9
  191. package/src/session/session-state.ts +25 -8
  192. package/src/session/signed-dialog.ts +60 -30
  193. package/src/session/user-prompts.ts +28 -3
  194. package/src/tan/challenge.ts +50 -36
  195. package/src/tan/flicker.ts +40 -29
  196. package/src/tan/tan-image.ts +9 -2
  197. package/src/tan/tan-models.ts +16 -2
  198. package/src/util/arguments.ts +40 -0
  199. package/src/util/async.ts +10 -4
  200. package/src/util/bytes.ts +3 -3
  201. package/src/util/dates.ts +52 -38
  202. package/src/util/errors.ts +9 -4
  203. package/src/util/immutable-map.ts +15 -12
  204. package/src/util/inspect.ts +14 -0
  205. package/src/util/text.ts +47 -5
  206. package/src/util/xml.ts +105 -38
  207. package/src/v3/bank-connection.ts +65 -6
  208. package/src/v3/dialog.ts +18 -8
  209. package/src/v3/message.ts +81 -72
  210. package/src/v3/parameter-store.ts +82 -16
  211. package/src/v3/parameters.ts +68 -41
  212. package/src/v3/protocol.ts +61 -28
  213. package/src/v3/queries.ts +26 -18
  214. package/src/v3/segments.ts +32 -20
  215. package/src/v3/syntax.ts +31 -14
package/src/v3/message.ts CHANGED
@@ -3,14 +3,13 @@
3
3
  import { BankMessage } from '../core/bank-info.js';
4
4
  import { FinTsFeedback } from '../core/feedback.js';
5
5
  import { TanMethod } from '../tan/tan-models.js';
6
- import { base64Encode, BytesBuilder, latin1Encode, utf8Decode } from '../util/bytes.js';
6
+ import { base64Encode, concatBytes, latin1Encode, utf8Decode } from '../util/bytes.js';
7
7
  import { formatFintsDate, formatFintsTime } from '../util/dates.js';
8
8
  import { FormatError } from '../util/errors.js';
9
9
  import { decodeBankText, decodeBase64Lines } from '../util/text.js';
10
10
  import {
11
11
  Binary,
12
- dataElementMemory,
13
- maxDataElements,
12
+ parsedElementBytes,
14
13
  parseSegments,
15
14
  type Segment,
16
15
  SegmentSpec,
@@ -77,10 +76,14 @@ function keyName(c: PinTanCredentials, keyType: string): SegmentValue {
77
76
  return [c.countryCode, c.bankCode, c.userId, keyType, 0, 0];
78
77
  }
79
78
 
80
- /** A random integer from 0 below `bound`, from a cryptographically secure source. */
79
+ /** A random integer from 0 below `bound`, each equally likely, from a cryptographically secure source. */
81
80
  function secureRandom(bound: number): number {
81
+ // Values from the last multiple of `bound` on cover only the low remainders, which they would make more likely.
82
+ const limit = 2 ** 32 - (2 ** 32 % bound);
82
83
  const values = new Uint32Array(1);
83
- crypto.getRandomValues(values);
84
+ do {
85
+ crypto.getRandomValues(values);
86
+ } while (values[0]! >= limit);
84
87
  return values[0]! % bound;
85
88
  }
86
89
 
@@ -93,20 +96,21 @@ function securityControlReference(): string {
93
96
  export class OutgoingMessage {
94
97
  constructor(
95
98
  readonly bytes: Uint8Array,
96
- /** The parts of the masked message, each decoded on its own as the bank's text is. */
97
- private readonly maskedParts: readonly Uint8Array[],
99
+ private readonly buildMasked: () => Uint8Array,
98
100
  ) {}
99
101
 
100
- /** The message with PIN and TAN masked; decoded only when a logger asks for it. */
102
+ /**
103
+ * The message with PIN and TAN masked; built only when a logger asks for it. It is decoded as a whole, as the bank's
104
+ * text is, so a message that mixes ISO 8859-1 text with UTF-8 text (`encodeText`) shows all of it as ISO 8859-1.
105
+ */
101
106
  get masked(): string {
102
- return this.maskedParts.map(decodeBankText).join('');
107
+ return decodeBankText(this.buildMasked());
103
108
  }
104
109
  }
105
110
 
106
- /** The segments between header and trailer, as sent and with PIN and TAN masked, and the number for the trailer. */
111
+ /** The segments between header and trailer, and the number of the trailer. */
107
112
  interface Body {
108
- readonly bytes: Uint8Array;
109
- readonly masked: readonly Uint8Array[];
113
+ readonly segments: readonly Uint8Array[];
110
114
  readonly nextNumber: number;
111
115
  }
112
116
 
@@ -116,7 +120,8 @@ interface Body {
116
120
  * With `security` the segments are signed with PIN and optional `tan` (HNSHK … HNSHA) and wrapped in the encryption
117
121
  * envelope (HNVSK, HNVSD) that PIN/TAN uses without actual encryption.
118
122
  *
119
- * The masked message states the sizes of its masked content, so that the log does not tell how long PIN and TAN are.
123
+ * The masked message is built as the message is, from the same time stamp and security control reference, with `***`
124
+ * for PIN and TAN, so that the log does not tell how long they are.
120
125
  */
121
126
  export function buildMessage(parts: {
122
127
  dialogId: string;
@@ -126,12 +131,21 @@ export function buildMessage(parts: {
126
131
  tan?: string | undefined;
127
132
  now?: Date;
128
133
  }): OutgoingMessage {
129
- const body =
130
- parts.security === undefined
131
- ? plainBody(parts.segments)
132
- : signedBody(parts.segments, parts.security, parts.tan, parts.now ?? new Date());
134
+ const { security, tan } = parts;
135
+ if (security === undefined) {
136
+ const bytes = withHeaderAndTrailer(parts, plainBody(parts.segments));
137
+ return new OutgoingMessage(bytes, () => bytes);
138
+ }
139
+ const time = parts.now ?? new Date();
140
+ const controlReference = securityControlReference();
141
+ const build = (pin: string, tanValue: string | undefined): Uint8Array =>
142
+ withHeaderAndTrailer(parts, signedBody(parts.segments, security, pin, tanValue, time, controlReference));
143
+ return new OutgoingMessage(build(security.pin, tan), () => build('***', tan === undefined ? undefined : '***'));
144
+ }
145
+
146
+ /** `body` between the header and the trailer of message `messageNumber` of the dialog `dialogId`. */
147
+ function withHeaderAndTrailer(parts: { dialogId: string; messageNumber: number }, body: Body): Uint8Array {
133
148
  const trailer = new SegmentSpec('HNHBS', 1, [parts.messageNumber]).build(body.nextNumber);
134
- // The header states the size of the whole message, itself included; its own size does not depend on it.
135
149
  const header = (size: number): Uint8Array =>
136
150
  new SegmentSpec('HNHBK', 3, [
137
151
  size.toString().padStart(12, '0'),
@@ -139,46 +153,27 @@ export function buildMessage(parts: {
139
153
  new Verbatim(parts.dialogId),
140
154
  parts.messageNumber,
141
155
  ]).build(1);
142
- const sizeOf = (sections: readonly Uint8Array[]): number => header(0).length + lengthOf(sections) + trailer.length;
143
- const result = new BytesBuilder();
144
- result.add(header(sizeOf([body.bytes])));
145
- result.add(body.bytes);
146
- result.add(trailer);
147
- return new OutgoingMessage(result.takeBytes(), [header(sizeOf(body.masked)), ...body.masked, trailer]);
148
- }
149
-
150
- /** The number of bytes in `parts` together. */
151
- function lengthOf(parts: readonly Uint8Array[]): number {
152
- return parts.reduce((sum, part) => sum + part.length, 0);
156
+ // The header states the size of the whole message, itself included; its own size does not depend on it.
157
+ const size = header(0).length + body.segments.reduce((sum, s) => sum + s.length, 0) + trailer.length;
158
+ return concatBytes([header(size), ...body.segments, trailer]);
153
159
  }
154
160
 
155
161
  /** The segments of a message without security, as the anonymous dialog sends them. */
156
162
  function plainBody(segments: readonly SegmentSpec[]): Body {
157
- const built = segments.map((segment, i) => segment.build(i + 2));
158
- const bytes = new BytesBuilder();
159
- built.forEach((b) => {
160
- bytes.add(b);
161
- });
162
- return { bytes: bytes.takeBytes(), masked: built, nextNumber: built.length + 2 };
163
+ return { segments: segments.map((segment, i) => segment.build(i + 2)), nextNumber: segments.length + 2 };
163
164
  }
164
165
 
165
- /** The segments signed with PIN and optional `tan`, inside the encryption envelope. */
166
+ /** The segments signed with `pin` and optional `tan`, inside the encryption envelope. */
166
167
  function signedBody(
167
168
  segments: readonly SegmentSpec[],
168
169
  security: PinTanCredentials,
170
+ pin: string,
169
171
  tan: string | undefined,
170
172
  time: Date,
173
+ controlReference: string,
171
174
  ): Body {
172
175
  const profile = ['PIN', isTwoStep(security) ? 2 : 1];
173
- const controlReference = securityControlReference();
174
- const inner = new BytesBuilder();
175
- const maskedInner: Uint8Array[] = [];
176
- const addInner = (built: Uint8Array, masked?: Uint8Array): void => {
177
- inner.add(built);
178
- maskedInner.push(masked ?? built);
179
- };
180
-
181
- addInner(
176
+ const inner = [
182
177
  new SegmentSpec('HNSHK', 4, [
183
178
  profile,
184
179
  security.securityFunction,
@@ -192,17 +187,10 @@ function signedBody(
192
187
  signatureAlgorithm,
193
188
  keyName(security, 'S'),
194
189
  ]).build(2),
195
- );
190
+ ];
196
191
  let number = 3;
197
- for (const segment of segments) addInner(segment.build(number++));
198
- const signatureEnd = (pin: string, tanValue: string | undefined): SegmentSpec =>
199
- new SegmentSpec('HNSHA', 2, [controlReference, null, [pin, tanValue]]);
200
- addInner(
201
- signatureEnd(security.pin, tan).build(number),
202
- signatureEnd('***', tan === undefined ? undefined : '***').build(number),
203
- );
204
-
205
- const innerBytes = inner.takeBytes();
192
+ for (const segment of segments) inner.push(segment.build(number++));
193
+ inner.push(new SegmentSpec('HNSHA', 2, [controlReference, null, [pin, tan]]).build(number));
206
194
  const encryptionHead = new SegmentSpec('HNVSK', 3, [
207
195
  profile,
208
196
  plainText,
@@ -213,16 +201,8 @@ function signedBody(
213
201
  keyName(security, 'V'),
214
202
  noCompression,
215
203
  ]).build(998);
216
- const bytes = new BytesBuilder();
217
- bytes.add(encryptionHead);
218
- bytes.add(new SegmentSpec('HNVSD', 1, [new Binary(innerBytes)]).build(999));
219
- const masked = [
220
- encryptionHead,
221
- latin1Encode(`HNVSD:999:1+@${lengthOf(maskedInner)}@`),
222
- ...maskedInner,
223
- latin1Encode("'"),
224
- ];
225
- return { bytes: bytes.takeBytes(), masked, nextNumber: number + 1 };
204
+ const encryptedData = new SegmentSpec('HNVSD', 1, [new Binary(concatBytes(inner))]).build(999);
205
+ return { segments: [encryptionHead, encryptedData], nextNumber: number + 1 };
226
206
  }
227
207
 
228
208
  /** A received message, with the segments of the encryption envelope unpacked. */
@@ -255,19 +235,29 @@ export class IncomingMessage3 {
255
235
  /**
256
236
  * Parses a message; throws `FormatError` if `data` is not one complete FinTS message, such as the HTML page of a
257
237
  * proxy, a maintenance notice, a message cut short, which lacks the message trailer (HNHBS) at its end, or two
258
- * messages run together, whose second would pass for part of the first.
238
+ * messages run together, whose second would pass for part of the first. It does as well for more than one encrypted
239
+ * part (HNVSD), each of which `parseSegments` would allow as many data elements as the whole message.
259
240
  *
260
- * Its `size` counts the memory of its data elements along with its bytes, since a message of many short elements
261
- * takes far more memory than bytes.
241
+ * Its `size` counts the memory of the data elements it keeps along with its bytes, since a message of many short
242
+ * elements takes far more memory than bytes.
262
243
  */
263
244
  static parse(data: Uint8Array): IncomingMessage3 {
264
- const budget = { elements: maxDataElements };
265
- const outer = parseSegments(data, budget);
245
+ let outer: Segment[] = [];
246
+ try {
247
+ outer = parseSegments(data);
248
+ } catch (error) {
249
+ // Text that is no FinTS message, such as a web page, seldom ends with a segment terminator; that it has no
250
+ // message header says more than what the parser trips over first.
251
+ if (!(error instanceof FormatError) || startsWithMessageHeader(data)) throw error;
252
+ }
266
253
  if (outer[0]?.type !== 'HNHBK') throw new FormatError('Not a FinTS message: no message header');
267
254
  if (outer.at(-1)?.type !== 'HNHBS') throw new FormatError('Incomplete FinTS message: no message trailer');
268
255
  if (outer.filter((s) => s.type === 'HNHBK' || s.type === 'HNHBS').length > 2) {
269
256
  throw new FormatError('More than one FinTS message');
270
257
  }
258
+ if (outer.filter((s) => s.type === 'HNVSD').length > 1) {
259
+ throw new FormatError('More than one encrypted part (HNVSD)');
260
+ }
271
261
  const segments: Segment[] = [];
272
262
  for (const s of outer) {
273
263
  if (s.type !== 'HNVSD') {
@@ -276,9 +266,11 @@ export class IncomingMessage3 {
276
266
  }
277
267
  const payload = s.binary(1);
278
268
  if (payload === undefined) throw new FormatError('Encrypted data (HNVSD) without binary data');
279
- for (const inner of parseSegments(payload, budget)) segments.push(inner);
269
+ for (const inner of parseSegments(payload)) segments.push(inner);
280
270
  }
281
- return new IncomingMessage3(segments, data.length + (maxDataElements - budget.elements) * dataElementMemory);
271
+ let elements = 0;
272
+ for (const s of segments) for (const group of s.elements) elements += group.length;
273
+ return new IncomingMessage3(segments, data.length + elements * parsedElementBytes);
282
274
  }
283
275
 
284
276
  static combine(first: IncomingMessage3, second: IncomingMessage3): IncomingMessage3 {
@@ -315,6 +307,23 @@ export class IncomingMessage3 {
315
307
  );
316
308
  }
317
309
 
310
+ /**
311
+ * Whether the message is the bank's dialog abort message (Formals B.7.6) for message `number` of the dialog
312
+ * `dialogId`: unencrypted, with nothing but the header, an error for the whole message (HIRMG) and the trailer. Its
313
+ * header names the dialog ID and the message number where the bank knows them, and `unbekannt` and 9999 where not.
314
+ */
315
+ aborts(number: number, dialogId: string | undefined): boolean {
316
+ const header = this.segments[0]!;
317
+ const id = header.verbatim(3);
318
+ const messageNumber = header.integer(4);
319
+ return (
320
+ this.segments.length === 3 &&
321
+ this.messageRejected &&
322
+ (id === 'unbekannt' || id === dialogId) &&
323
+ (messageNumber === 9999 || messageNumber === number)
324
+ );
325
+ }
326
+
318
327
  find(type: string): Segment | undefined {
319
328
  return this.segments.find((s) => s.type === type);
320
329
  }
@@ -3,8 +3,19 @@
3
3
  import type { FinTsConfig } from '../core/config.js';
4
4
  import { FormatError } from '../util/errors.js';
5
5
  import { readOptionalBoolean, readOptionalObject, readOptionalString, type JsonObject } from '../util/json.js';
6
+ import { trimBlanks } from '../util/text.js';
6
7
  import type { IncomingMessage3 } from './message.js';
7
8
  import { Bpd3, decodeSegments, Upd3 } from './parameters.js';
9
+ import { Verbatim } from './syntax.js';
10
+
11
+ /** The limits the bank parameter data set on the messages the client sends (`ParameterStore.limits`). */
12
+ export interface MessageLimits {
13
+ /** The shortest and longest PIN a login may carry. */
14
+ readonly pinLength: { readonly min: number | undefined; readonly max: number | undefined } | undefined;
15
+
16
+ /** The size of the largest message, in kilobytes. */
17
+ readonly maxMessageSize: number | undefined;
18
+ }
8
19
 
9
20
  /**
10
21
  * The parameter data and IDs the bank sends the user `config` names, which the messages and business transactions
@@ -22,11 +33,15 @@ export class ParameterStore {
22
33
  * the bank send its parameter data anew.
23
34
  */
24
35
  private bpdAnonymous = false;
36
+
37
+ /** The limits a refresh took from the anonymous dialog's parameter data (`keepRefreshedLimits`). */
38
+ private refreshedLimits: MessageLimits | undefined;
25
39
  private upd: Upd3 | undefined;
26
40
 
27
41
  /**
28
- * The names of the TAN media in the bank's last list whose text differs from what the bank sent, by their text, as
29
- * `parseTanMedia` notes them. Saved with the state, as the medium in use is.
42
+ * The names of the TAN media in the bank's last list whose text would not go back as the bank sent them, by their
43
+ * text, as `parseTanMedia` notes them (`readTanMediumNames` for a state of 0.2.0). Saved with the state, as the
44
+ * medium in use is.
30
45
  */
31
46
  private tanMediumNames = new Map<string, string>();
32
47
 
@@ -50,9 +65,21 @@ export class ParameterStore {
50
65
  return this.systemIdValue;
51
66
  }
52
67
 
53
- /** The version of the bank parameter data a dialog reports: 0 where there are none, or only the anonymous dialog's. */
68
+ /**
69
+ * The limits on the messages the client sends: those of the bank parameter data, unless a refresh took others
70
+ * (`keepRefreshedLimits`).
71
+ */
72
+ get limits(): MessageLimits {
73
+ const bpd = this.bpd;
74
+ return this.refreshedLimits ?? { pinLength: bpd?.pinTan.pinLength, maxMessageSize: bpd?.bankInfo.maxMessageSize };
75
+ }
76
+
77
+ /**
78
+ * The version of the bank parameter data a dialog reports: 0 where there are none, only the anonymous dialog's, or
79
+ * where limits a refresh took stand in for theirs (`keepRefreshedLimits`).
80
+ */
54
81
  get reportedBpdVersion(): number {
55
- return this.bpd === undefined || this.bpdAnonymous ? 0 : this.bpd.version;
82
+ return this.bpd === undefined || this.bpdAnonymous || this.refreshedLimits !== undefined ? 0 : this.bpd.version;
56
83
  }
57
84
 
58
85
  /**
@@ -99,11 +126,28 @@ export class ParameterStore {
99
126
  if (this.bpd === undefined || this.bpdAnonymous) this.keepBankParameters(response, true);
100
127
  }
101
128
 
129
+ /**
130
+ * Keeps the limits of the bank parameter data in `response`, the answer of an anonymous dialog the client asked for
131
+ * after the limits kept ruled out a login: those may be outdated, as saved ones are. Parameter data of the version
132
+ * kept tell nothing new, and the bank may send a user other ones than the anonymous dialog (PIN/TAN B.4.3.1), so of
133
+ * those of another version it keeps the limits only, and the PIN lengths only where they state them (HIPINS). They
134
+ * stand in for those kept until a dialog signed with the PIN brings parameter data.
135
+ */
136
+ keepRefreshedLimits(response: IncomingMessage3): void {
137
+ const fresh = Bpd3.fromSegments(response.segments, this.config.bankCode);
138
+ if (fresh === undefined || fresh.version === this.bpd?.version) return;
139
+ this.refreshedLimits = {
140
+ pinLength: fresh.pinTan.pinLength ?? this.limits.pinLength,
141
+ maxMessageSize: fresh.bankInfo.maxMessageSize,
142
+ };
143
+ }
144
+
102
145
  private keepBankParameters(response: IncomingMessage3, anonymous: boolean): void {
103
146
  const bpd = Bpd3.fromSegments(response.segments, this.config.bankCode);
104
147
  if (bpd === undefined) return;
105
148
  this.bpd = bpd;
106
149
  this.bpdAnonymous = anonymous;
150
+ this.refreshedLimits = undefined;
107
151
  }
108
152
 
109
153
  /** Keeps `names`, the names of the bank's latest list of TAN media as `parseTanMedia` notes them. */
@@ -111,9 +155,13 @@ export class ParameterStore {
111
155
  this.tanMediumNames = names;
112
156
  }
113
157
 
114
- /** The TAN medium `name` as the bank sent it in its list of TAN media; `name` itself for one it did not list. */
115
- tanMediumAsSent(name: string): string {
116
- return this.tanMediumNames.get(name) ?? name;
158
+ /**
159
+ * The TAN medium `name` to send: as the bank sent it, where its last list of TAN media noted that (`parseTanMedia`);
160
+ * otherwise as text, which goes out without the blanks at either end a name the app passes may have.
161
+ */
162
+ tanMediumToSend(name: string): string | Verbatim {
163
+ const asSent = this.tanMediumNames.get(name);
164
+ return asSent === undefined ? name : new Verbatim(asSent);
117
165
  }
118
166
 
119
167
  /** The parameter data and IDs as JSON data, for the client's saved state. */
@@ -124,37 +172,55 @@ export class ParameterStore {
124
172
  bpdAnonymous: this.bpdAnonymous,
125
173
  upd: this.upd?.encoded ?? null,
126
174
  tanMediumNames: Object.fromEntries(this.tanMediumNames),
175
+ [blanksNotedKey]: true,
127
176
  };
128
177
  }
129
178
 
130
- /** Restores what `saveState` saved; throws if `saved` is damaged, leaving the store unchanged. */
131
- restoreState(saved: JsonObject): void {
179
+ /**
180
+ * Restores what `saveState` saved, with `tanMedium`, the medium in use the state holds; throws if `saved` is damaged,
181
+ * leaving the store unchanged.
182
+ */
183
+ restoreState(saved: JsonObject, tanMedium?: string): void {
132
184
  const bpd = readOptionalString(saved, 'bpd');
133
185
  const upd = readOptionalString(saved, 'upd');
134
186
  const restoredSystemId = readOptionalString(saved, 'systemId');
135
- // A state of the Dart package or an earlier version does not tell, so its parameter data are fetched anew.
187
+ // A state of fints-ts 0.1 or the Dart package 0.2.0 does not tell, so its parameter data are fetched anew.
136
188
  const restoredBpdAnonymous = readOptionalBoolean(saved, 'bpdAnonymous') ?? true;
137
189
  const restoredBpd = bpd === undefined ? undefined : Bpd3.fromSegments(decodeSegments(bpd), this.config.bankCode);
138
190
  const restoredUpd =
139
191
  upd === undefined
140
192
  ? undefined
141
193
  : Upd3.fromSegments(decodeSegments(upd), this.config.bankCode, this.config.countryCode);
142
- const restoredNames = readTanMediumNames(saved);
194
+ const restoredNames = readTanMediumNames(saved, tanMedium);
143
195
  this.systemIdValue = restoredSystemId;
144
196
  this.bpd = restoredBpd;
145
197
  this.bpdAnonymous = restoredBpdAnonymous;
198
+ this.refreshedLimits = undefined;
146
199
  this.upd = restoredUpd;
147
200
  this.tanMediumNames = restoredNames;
148
201
  }
149
202
  }
150
203
 
151
- /** The TAN medium names `saveState` saved; none in a state of the Dart package or an earlier version. */
152
- function readTanMediumNames(saved: JsonObject): Map<string, string> {
153
- const names = readOptionalObject(saved, 'tanMediumNames') ?? {};
154
- return new Map(
155
- Object.entries(names).map(([name, asSent]) => {
204
+ /** The key of the saved state that tells the TAN medium names apart from those version 0.2.0 saved. */
205
+ const blanksNotedKey = 'tanMediumBlanksNoted';
206
+
207
+ /**
208
+ * The TAN medium names `saveState` saved; none in a state of fints-ts 0.1 or the Dart package 0.2.0.
209
+ *
210
+ * Version 0.2.0 noted no name with blanks at either end, and sent every name as it is, so `tanMedium` keeps going out
211
+ * with its blanks from such a state: the bank may have listed it so.
212
+ */
213
+ function readTanMediumNames(saved: JsonObject, tanMedium: string | undefined): Map<string, string> {
214
+ const listed = readOptionalObject(saved, 'tanMediumNames');
215
+ const names = new Map(
216
+ Object.entries(listed ?? {}).map(([name, asSent]) => {
156
217
  if (typeof asSent !== 'string') throw new FormatError('Saved state: a TAN medium name is not a string');
157
218
  return [name, asSent];
158
219
  }),
159
220
  );
221
+ const savedBy020 = listed !== undefined && readOptionalBoolean(saved, blanksNotedKey) !== true;
222
+ if (savedBy020 && tanMedium !== undefined && trimBlanks(tanMedium) !== tanMedium && !names.has(tanMedium)) {
223
+ names.set(tanMedium, tanMedium);
224
+ }
225
+ return names;
160
226
  }
@@ -6,7 +6,7 @@ import { BankInfo } from '../core/bank-info.js';
6
6
  import { FinTsProtocolError } from '../core/errors.js';
7
7
  import { TanMethod, tanMediumRequirementOf } from '../tan/tan-models.js';
8
8
  import { base64Decode, base64Encode, concatBytes } from '../util/bytes.js';
9
- import { nonBlank, tryParseInt } from '../util/text.js';
9
+ import { nonBlank, trimBlanks, tryParseInt } from '../util/text.js';
10
10
  import { AccountExtension } from './account-extension.js';
11
11
  import { parseSegments, type Segment } from './syntax.js';
12
12
 
@@ -33,6 +33,12 @@ const Hibpa = {
33
33
  maxTimeout: 9,
34
34
  } as const;
35
35
 
36
+ /** The size or time at `index` of `hibpa`, a field of at most four digits; `undefined` for a larger value, as for none. */
37
+ function hibpaLimit(hibpa: Segment, index: number): number | undefined {
38
+ const value = hibpa.integer(index);
39
+ return value !== undefined && value <= 9999 ? value : undefined;
40
+ }
41
+
36
42
  /**
37
43
  * The data element of every parameter segment (`HI…S`) that holds its specific parameters, after the maximum number of
38
44
  * orders, the minimum number of signatures and the security class.
@@ -66,9 +72,15 @@ export class Bpd3 {
66
72
  readonly unsupportedTanMethods: readonly string[];
67
73
  readonly pinTan: PinTanParameters;
68
74
 
69
- /** camt formats the bank offers for HKCAZ. */
75
+ /** The camt.052 formats the bank offers for HKCAZ, the one camt message the client reads. */
70
76
  readonly camtFormats: readonly string[];
71
77
 
78
+ /**
79
+ * Whether an international account (KTI) may name the national account as well: HISPAS does not rule it out
80
+ * ("Nationale Kontoverbindung erlaubt").
81
+ */
82
+ readonly nationalAccountAllowed: boolean;
83
+
72
84
  /** Versions of each parameter segment, by segment type. */
73
85
  private readonly versions: ReadonlyMap<string, ReadonlySet<number>>;
74
86
 
@@ -78,22 +90,19 @@ export class Bpd3 {
78
90
  */
79
91
  readonly dialogLanguage: number;
80
92
 
81
- private encodedValue: string | undefined;
93
+ /** The segments as the saved state keeps them. */
94
+ readonly encoded: string;
82
95
 
83
- private constructor(
84
- readonly segments: readonly Segment[],
85
- hibpa: Segment,
86
- fallbackBankCode: string,
87
- ) {
96
+ private constructor(segments: readonly Segment[], hibpa: Segment, fallbackBankCode: string) {
88
97
  this.bankInfo = new BankInfo({
89
98
  bankCode: hibpa.text(Hibpa.bankId, 1) ?? fallbackBankCode,
90
99
  name: hibpa.text(Hibpa.bankName),
91
100
  bpdVersion: hibpa.integer(Hibpa.version) ?? 0,
92
101
  languages: defined(hibpa.texts(Hibpa.languages)),
93
102
  supportedVersions: defined(hibpa.texts(Hibpa.fintsVersions)),
94
- maxMessageSize: hibpa.integer(Hibpa.maxMessageSize),
95
- minTimeout: hibpa.integer(Hibpa.minTimeout),
96
- maxTimeout: hibpa.integer(Hibpa.maxTimeout),
103
+ maxMessageSize: hibpaLimit(hibpa, Hibpa.maxMessageSize),
104
+ minTimeout: hibpaLimit(hibpa, Hibpa.minTimeout),
105
+ maxTimeout: hibpaLimit(hibpa, Hibpa.maxTimeout),
97
106
  });
98
107
  this.offeredMethods = parseTanMethods(segments);
99
108
  const offered = [...this.offeredMethods.values()];
@@ -105,9 +114,11 @@ export class Bpd3 {
105
114
  this.camtFormats = segments
106
115
  .filter((s) => s.type === 'HICAZS')
107
116
  .flatMap((s) => defined(s.texts(parameterGroup)))
108
- .filter((format) => format.includes('camt'));
117
+ .filter((format) => format.includes('camt.052'));
118
+ this.nationalAccountAllowed = highest(segments, 'HISPAS')?.text(parameterGroup, 1) !== 'N';
109
119
  this.versions = versionsByType(segments);
110
120
  this.dialogLanguage = dialogLanguageOf(segments, this.bankInfo);
121
+ this.encoded = encodeSegments(segments);
111
122
  }
112
123
 
113
124
  /** The version of the parameter data, which the client reports back so that the bank sends newer ones only. */
@@ -115,11 +126,6 @@ export class Bpd3 {
115
126
  return this.bankInfo.bpdVersion;
116
127
  }
117
128
 
118
- /** The segments as the saved state keeps them, encoded once. */
119
- get encoded(): string {
120
- return (this.encodedValue ??= encodeSegments(this.segments));
121
- }
122
-
123
129
  /** The BPD in `all`, or `undefined` if they contain no HIBPA segment. */
124
130
  static fromSegments(all: readonly Segment[], fallbackBankCode: string): Bpd3 | undefined {
125
131
  const segments = all.filter(isParameterSegment);
@@ -154,13 +160,23 @@ function dialogLanguageOf(segments: readonly Segment[], bankInfo: BankInfo): num
154
160
  return bankInfo.languages.map((l) => tryParseInt(l)).find((l) => l !== undefined && languages.has(l)) ?? 0;
155
161
  }
156
162
 
163
+ const generalParameterSegments = new Set(['HIBPA', 'HIKOM', 'HISHV', 'HIKPV']);
164
+
157
165
  function isParameterSegment(s: Segment): boolean {
158
166
  const type = s.type;
159
- if (['HIBPA', 'HIKOM', 'HISHV', 'HIKPV'].includes(type)) return true;
160
- if (type === 'HIRMS' || type === 'HIRMG') return false;
167
+ if (generalParameterSegments.has(type)) return true;
168
+ // Return codes for single segments end with an S, as parameter segments do.
169
+ if (type === 'HIRMS') return false;
161
170
  return type.length >= 5 && (type.startsWith('HI') || type.startsWith('DI')) && type.endsWith('S');
162
171
  }
163
172
 
173
+ /** The segment of `type` with the highest version in `segments`. */
174
+ function highest(segments: readonly Segment[], type: string): Segment | undefined {
175
+ let found: Segment | undefined;
176
+ for (const s of segments) if (s.type === type && (found === undefined || s.version > found.version)) found = s;
177
+ return found;
178
+ }
179
+
164
180
  function versionsByType(segments: readonly Segment[]): Map<string, Set<number>> {
165
181
  const versions = new Map<string, Set<number>>();
166
182
  for (const s of segments) {
@@ -270,17 +286,14 @@ function parseHitans(s: Segment): OfferedMethod[] {
270
286
  export class PinTanParameters {
271
287
  private constructor(
272
288
  private readonly tanRequired: ReadonlyMap<string, boolean>,
273
- /** The shortest and longest PIN the bank takes. */
274
- readonly pinLength: { min: number | undefined; max: number | undefined },
289
+ /** The shortest and longest PIN the bank takes; `undefined` where the bank parameter data have no HIPINS. */
290
+ readonly pinLength: { min: number | undefined; max: number | undefined } | undefined,
275
291
  ) {}
276
292
 
277
293
  /** Reads the HIPINS segment of the highest version. */
278
294
  static parse(segments: readonly Segment[]): PinTanParameters {
279
- let hipins: Segment | undefined;
280
- for (const s of segments.filter((s) => s.type === 'HIPINS')) {
281
- if (hipins === undefined || s.version > hipins.version) hipins = s;
282
- }
283
- if (hipins === undefined) return new PinTanParameters(new Map(), { min: undefined, max: undefined });
295
+ const hipins = highest(segments, 'HIPINS');
296
+ if (hipins === undefined) return new PinTanParameters(new Map(), undefined);
284
297
  const fields = hipins.texts(parameterGroup);
285
298
  const tanRequired = new Map<string, boolean>();
286
299
  // Five general fields, the PIN lengths first; then pairs of segment type and TAN flag.
@@ -338,18 +351,19 @@ export class Upd3 {
338
351
  /** The accounts the user may access. */
339
352
  readonly accounts: readonly Account[];
340
353
 
341
- private encodedValue: string | undefined;
354
+ /** The segments as the saved state keeps them. */
355
+ readonly encoded: string;
342
356
 
343
357
  /** `listsAll` tells whether HIUPA declares the lists of permitted transactions complete ("UPD-Verwendung" 0). */
344
358
  private constructor(
345
- readonly segments: readonly Segment[],
359
+ segments: readonly Segment[],
346
360
  hiupa: Segment,
347
361
  listsAll: boolean,
348
362
  defaultBankCode: string,
349
363
  defaultCountry: string,
350
364
  ) {
351
365
  const hiupds = segments.filter((s) => s.type === 'HIUPD');
352
- this.userId = hiupa.text(hiupaUserId)?.trim();
366
+ this.userId = nonBlank(hiupa.text(hiupaUserId));
353
367
  this.version = hiupa.integer(hiupaVersion) ?? 0;
354
368
  this.listsAllTransactions = listsAll;
355
369
  this.listedTransactions = new Set(hiupds.flatMap(transactionsOf));
@@ -357,11 +371,7 @@ export class Upd3 {
357
371
  hiupds.map((s) => entryOf(s, defaultBankCode, defaultCountry, listsAll)).filter((e) => e !== undefined),
358
372
  );
359
373
  this.accounts = Object.freeze(this.entries.map((e) => e.account));
360
- }
361
-
362
- /** The segments as the saved state keeps them, encoded once. */
363
- get encoded(): string {
364
- return (this.encodedValue ??= encodeSegments(this.segments));
374
+ this.encoded = encodeSegments(segments);
365
375
  }
366
376
 
367
377
  /**
@@ -407,11 +417,13 @@ function transactionsOf(s: Segment): string[] {
407
417
  function extensionOf(s: Segment): AccountExtension | undefined {
408
418
  if (s.version < 6) return undefined;
409
419
  for (let i = allowedFrom(s.version); i < s.length; i++) {
410
- const text = s
411
- .group(i)
412
- .map((e) => e.text)
413
- .join(':');
414
- if (text.trimStart().startsWith('{')) return AccountExtension.tryParse(text);
420
+ const text = trimBlanks(
421
+ s
422
+ .group(i)
423
+ .map((e) => e.text)
424
+ .join(':'),
425
+ );
426
+ if (text.startsWith('{')) return AccountExtension.tryParse(text);
415
427
  }
416
428
  return undefined;
417
429
  }
@@ -426,6 +438,22 @@ function entryOf(s: Segment, defaultBankCode: string, defaultCountry: string, li
426
438
  };
427
439
  }
428
440
 
441
+ /** Whether `code` is a country code, three digits, such as `280`; a German bank code has eight. */
442
+ function isCountryCode(code: string | undefined): boolean {
443
+ return code !== undefined && /^[0-9]{3}$/.test(code);
444
+ }
445
+
446
+ /**
447
+ * The national account of the HIUPD `s`: number, sub-account, country and bank code (KTV2), by position in every
448
+ * version, so that a sub-account or bank code the bank leaves out is empty. Three elements whose second is a country
449
+ * code and whose third is not are the older layout without sub-account: number, country and bank code.
450
+ */
451
+ function nationalAccountOf(s: Segment): (string | undefined)[] {
452
+ const ktv = s.texts(1);
453
+ const [number, second, third] = ktv;
454
+ return ktv.length === 3 && isCountryCode(second) && !isCountryCode(third) ? [number, undefined, second, third] : ktv;
455
+ }
456
+
429
457
  function accountOf(
430
458
  s: Segment,
431
459
  defaultBankCode: string,
@@ -433,8 +461,7 @@ function accountOf(
433
461
  listsAll: boolean,
434
462
  ): Account | undefined {
435
463
  const version = s.version;
436
- // Every version names the account as KTV2, by position: a sub-account or bank code the bank leaves out is empty.
437
- const [number, subAccount, country, bank] = s.texts(1);
464
+ const [number, subAccount, country, bank] = nationalAccountOf(s);
438
465
  const iban = version >= 6 ? s.text(2) : undefined;
439
466
  if (number === undefined && iban === undefined) return undefined;
440
467
  // Field positions by HIUPD version: version 6 inserts the IBAN, version 5 the account type.