@coderoftime/fints 0.0.0-stage → 0.2.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 (249) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/LICENSE +28 -0
  3. package/README.md +246 -2
  4. package/dist/client.d.ts +48 -0
  5. package/dist/client.d.ts.map +1 -0
  6. package/dist/client.js +199 -0
  7. package/dist/client.js.map +1 -0
  8. package/dist/core/account.d.ts +168 -0
  9. package/dist/core/account.d.ts.map +1 -0
  10. package/dist/core/account.js +245 -0
  11. package/dist/core/account.js.map +1 -0
  12. package/dist/core/amount.d.ts +63 -0
  13. package/dist/core/amount.d.ts.map +1 -0
  14. package/dist/core/amount.js +192 -0
  15. package/dist/core/amount.js.map +1 -0
  16. package/dist/core/balance.d.ts +55 -0
  17. package/dist/core/balance.d.ts.map +1 -0
  18. package/dist/core/balance.js +54 -0
  19. package/dist/core/balance.js.map +1 -0
  20. package/dist/core/bank-info.d.ts +49 -0
  21. package/dist/core/bank-info.d.ts.map +1 -0
  22. package/dist/core/bank-info.js +52 -0
  23. package/dist/core/bank-info.js.map +1 -0
  24. package/dist/core/client.d.ts +161 -0
  25. package/dist/core/client.d.ts.map +1 -0
  26. package/dist/core/client.js +2 -0
  27. package/dist/core/client.js.map +1 -0
  28. package/dist/core/config.d.ts +80 -0
  29. package/dist/core/config.d.ts.map +1 -0
  30. package/dist/core/config.js +153 -0
  31. package/dist/core/config.js.map +1 -0
  32. package/dist/core/errors.d.ts +153 -0
  33. package/dist/core/errors.d.ts.map +1 -0
  34. package/dist/core/errors.js +247 -0
  35. package/dist/core/errors.js.map +1 -0
  36. package/dist/core/feedback.d.ts +72 -0
  37. package/dist/core/feedback.d.ts.map +1 -0
  38. package/dist/core/feedback.js +127 -0
  39. package/dist/core/feedback.js.map +1 -0
  40. package/dist/core/transaction.d.ts +103 -0
  41. package/dist/core/transaction.d.ts.map +1 -0
  42. package/dist/core/transaction.js +96 -0
  43. package/dist/core/transaction.js.map +1 -0
  44. package/dist/core/transport.d.ts +64 -0
  45. package/dist/core/transport.d.ts.map +1 -0
  46. package/dist/core/transport.js +149 -0
  47. package/dist/core/transport.js.map +1 -0
  48. package/dist/formats/camt.d.ts +32 -0
  49. package/dist/formats/camt.d.ts.map +1 -0
  50. package/dist/formats/camt.js +242 -0
  51. package/dist/formats/camt.js.map +1 -0
  52. package/dist/formats/mt940.d.ts +54 -0
  53. package/dist/formats/mt940.d.ts.map +1 -0
  54. package/dist/formats/mt940.js +376 -0
  55. package/dist/formats/mt940.js.map +1 -0
  56. package/dist/formats/readers.d.ts +15 -0
  57. package/dist/formats/readers.d.ts.map +1 -0
  58. package/dist/formats/readers.js +42 -0
  59. package/dist/formats/readers.js.map +1 -0
  60. package/dist/formats/record-budget.d.ts +7 -0
  61. package/dist/formats/record-budget.d.ts.map +1 -0
  62. package/dist/formats/record-budget.js +17 -0
  63. package/dist/formats/record-budget.js.map +1 -0
  64. package/dist/formats/sepa-purpose.d.ts +28 -0
  65. package/dist/formats/sepa-purpose.d.ts.map +1 -0
  66. package/dist/formats/sepa-purpose.js +89 -0
  67. package/dist/formats/sepa-purpose.js.map +1 -0
  68. package/dist/index.d.ts +28 -0
  69. package/dist/index.d.ts.map +1 -0
  70. package/dist/index.js +27 -0
  71. package/dist/index.js.map +1 -0
  72. package/dist/session/app-approval.d.ts +66 -0
  73. package/dist/session/app-approval.d.ts.map +1 -0
  74. package/dist/session/app-approval.js +226 -0
  75. package/dist/session/app-approval.js.map +1 -0
  76. package/dist/session/dialog-runner.d.ts +31 -0
  77. package/dist/session/dialog-runner.d.ts.map +1 -0
  78. package/dist/session/dialog-runner.js +111 -0
  79. package/dist/session/dialog-runner.js.map +1 -0
  80. package/dist/session/login-guard.d.ts +78 -0
  81. package/dist/session/login-guard.d.ts.map +1 -0
  82. package/dist/session/login-guard.js +181 -0
  83. package/dist/session/login-guard.js.map +1 -0
  84. package/dist/session/sepa-accounts.d.ts +40 -0
  85. package/dist/session/sepa-accounts.d.ts.map +1 -0
  86. package/dist/session/sepa-accounts.js +112 -0
  87. package/dist/session/sepa-accounts.js.map +1 -0
  88. package/dist/session/session-setup.d.ts +80 -0
  89. package/dist/session/session-setup.d.ts.map +1 -0
  90. package/dist/session/session-setup.js +182 -0
  91. package/dist/session/session-setup.js.map +1 -0
  92. package/dist/session/session-state.d.ts +50 -0
  93. package/dist/session/session-state.d.ts.map +1 -0
  94. package/dist/session/session-state.js +182 -0
  95. package/dist/session/session-state.js.map +1 -0
  96. package/dist/session/signed-dialog.d.ts +77 -0
  97. package/dist/session/signed-dialog.d.ts.map +1 -0
  98. package/dist/session/signed-dialog.js +170 -0
  99. package/dist/session/signed-dialog.js.map +1 -0
  100. package/dist/session/tan-selection.d.ts +96 -0
  101. package/dist/session/tan-selection.d.ts.map +1 -0
  102. package/dist/session/tan-selection.js +189 -0
  103. package/dist/session/tan-selection.js.map +1 -0
  104. package/dist/session/user-prompts.d.ts +65 -0
  105. package/dist/session/user-prompts.d.ts.map +1 -0
  106. package/dist/session/user-prompts.js +184 -0
  107. package/dist/session/user-prompts.js.map +1 -0
  108. package/dist/tan/challenge.d.ts +135 -0
  109. package/dist/tan/challenge.d.ts.map +1 -0
  110. package/dist/tan/challenge.js +184 -0
  111. package/dist/tan/challenge.js.map +1 -0
  112. package/dist/tan/flicker.d.ts +42 -0
  113. package/dist/tan/flicker.d.ts.map +1 -0
  114. package/dist/tan/flicker.js +296 -0
  115. package/dist/tan/flicker.js.map +1 -0
  116. package/dist/tan/tan-image.d.ts +22 -0
  117. package/dist/tan/tan-image.d.ts.map +1 -0
  118. package/dist/tan/tan-image.js +43 -0
  119. package/dist/tan/tan-image.js.map +1 -0
  120. package/dist/tan/tan-models.d.ts +149 -0
  121. package/dist/tan/tan-models.d.ts.map +1 -0
  122. package/dist/tan/tan-models.js +153 -0
  123. package/dist/tan/tan-models.js.map +1 -0
  124. package/dist/util/async.d.ts +22 -0
  125. package/dist/util/async.d.ts.map +1 -0
  126. package/dist/util/async.js +45 -0
  127. package/dist/util/async.js.map +1 -0
  128. package/dist/util/bytes.d.ts +22 -0
  129. package/dist/util/bytes.d.ts.map +1 -0
  130. package/dist/util/bytes.js +128 -0
  131. package/dist/util/bytes.js.map +1 -0
  132. package/dist/util/call-context.d.ts +12 -0
  133. package/dist/util/call-context.d.ts.map +1 -0
  134. package/dist/util/call-context.js +30 -0
  135. package/dist/util/call-context.js.map +1 -0
  136. package/dist/util/dates.d.ts +55 -0
  137. package/dist/util/dates.d.ts.map +1 -0
  138. package/dist/util/dates.js +189 -0
  139. package/dist/util/dates.js.map +1 -0
  140. package/dist/util/errors.d.ts +17 -0
  141. package/dist/util/errors.d.ts.map +1 -0
  142. package/dist/util/errors.js +24 -0
  143. package/dist/util/errors.js.map +1 -0
  144. package/dist/util/immutable-map.d.ts +16 -0
  145. package/dist/util/immutable-map.d.ts.map +1 -0
  146. package/dist/util/immutable-map.js +38 -0
  147. package/dist/util/immutable-map.js.map +1 -0
  148. package/dist/util/json.d.ts +17 -0
  149. package/dist/util/json.d.ts.map +1 -0
  150. package/dist/util/json.js +62 -0
  151. package/dist/util/json.js.map +1 -0
  152. package/dist/util/text.d.ts +37 -0
  153. package/dist/util/text.d.ts.map +1 -0
  154. package/dist/util/text.js +107 -0
  155. package/dist/util/text.js.map +1 -0
  156. package/dist/util/xml.d.ts +36 -0
  157. package/dist/util/xml.d.ts.map +1 -0
  158. package/dist/util/xml.js +194 -0
  159. package/dist/util/xml.js.map +1 -0
  160. package/dist/v3/account-extension.d.ts +25 -0
  161. package/dist/v3/account-extension.d.ts.map +1 -0
  162. package/dist/v3/account-extension.js +173 -0
  163. package/dist/v3/account-extension.js.map +1 -0
  164. package/dist/v3/bank-connection.d.ts +34 -0
  165. package/dist/v3/bank-connection.d.ts.map +1 -0
  166. package/dist/v3/bank-connection.js +58 -0
  167. package/dist/v3/bank-connection.js.map +1 -0
  168. package/dist/v3/dialog.d.ts +112 -0
  169. package/dist/v3/dialog.d.ts.map +1 -0
  170. package/dist/v3/dialog.js +241 -0
  171. package/dist/v3/dialog.js.map +1 -0
  172. package/dist/v3/message.d.ts +85 -0
  173. package/dist/v3/message.d.ts.map +1 -0
  174. package/dist/v3/message.js +303 -0
  175. package/dist/v3/message.js.map +1 -0
  176. package/dist/v3/parameter-store.d.ts +75 -0
  177. package/dist/v3/parameter-store.d.ts.map +1 -0
  178. package/dist/v3/parameter-store.js +143 -0
  179. package/dist/v3/parameter-store.js.map +1 -0
  180. package/dist/v3/parameters.d.ts +105 -0
  181. package/dist/v3/parameters.d.ts.map +1 -0
  182. package/dist/v3/parameters.js +450 -0
  183. package/dist/v3/parameters.js.map +1 -0
  184. package/dist/v3/protocol.d.ts +61 -0
  185. package/dist/v3/protocol.d.ts.map +1 -0
  186. package/dist/v3/protocol.js +113 -0
  187. package/dist/v3/protocol.js.map +1 -0
  188. package/dist/v3/queries.d.ts +51 -0
  189. package/dist/v3/queries.d.ts.map +1 -0
  190. package/dist/v3/queries.js +120 -0
  191. package/dist/v3/queries.js.map +1 -0
  192. package/dist/v3/segments.d.ts +106 -0
  193. package/dist/v3/segments.d.ts.map +1 -0
  194. package/dist/v3/segments.js +325 -0
  195. package/dist/v3/segments.js.map +1 -0
  196. package/dist/v3/syntax.d.ts +127 -0
  197. package/dist/v3/syntax.d.ts.map +1 -0
  198. package/dist/v3/syntax.js +367 -0
  199. package/dist/v3/syntax.js.map +1 -0
  200. package/package.json +67 -4
  201. package/src/client.ts +252 -0
  202. package/src/core/account.ts +323 -0
  203. package/src/core/amount.ts +211 -0
  204. package/src/core/balance.ts +85 -0
  205. package/src/core/bank-info.ts +66 -0
  206. package/src/core/client.ts +186 -0
  207. package/src/core/config.ts +182 -0
  208. package/src/core/errors.ts +277 -0
  209. package/src/core/feedback.ts +140 -0
  210. package/src/core/transaction.ts +161 -0
  211. package/src/core/transport.ts +189 -0
  212. package/src/formats/camt.ts +266 -0
  213. package/src/formats/mt940.ts +419 -0
  214. package/src/formats/readers.ts +49 -0
  215. package/src/formats/record-budget.ts +18 -0
  216. package/src/formats/sepa-purpose.ts +94 -0
  217. package/src/index.ts +70 -0
  218. package/src/session/app-approval.ts +278 -0
  219. package/src/session/dialog-runner.ts +122 -0
  220. package/src/session/login-guard.ts +221 -0
  221. package/src/session/sepa-accounts.ts +115 -0
  222. package/src/session/session-setup.ts +184 -0
  223. package/src/session/session-state.ts +204 -0
  224. package/src/session/signed-dialog.ts +192 -0
  225. package/src/session/tan-selection.ts +203 -0
  226. package/src/session/user-prompts.ts +193 -0
  227. package/src/tan/challenge.ts +261 -0
  228. package/src/tan/flicker.ts +308 -0
  229. package/src/tan/tan-image.ts +38 -0
  230. package/src/tan/tan-models.ts +252 -0
  231. package/src/util/async.ts +48 -0
  232. package/src/util/bytes.ts +129 -0
  233. package/src/util/call-context.ts +41 -0
  234. package/src/util/dates.ts +210 -0
  235. package/src/util/errors.ts +27 -0
  236. package/src/util/immutable-map.ts +47 -0
  237. package/src/util/json.ts +67 -0
  238. package/src/util/text.ts +112 -0
  239. package/src/util/xml.ts +183 -0
  240. package/src/v3/account-extension.ts +185 -0
  241. package/src/v3/bank-connection.ts +67 -0
  242. package/src/v3/dialog.ts +283 -0
  243. package/src/v3/message.ts +373 -0
  244. package/src/v3/parameter-store.ts +160 -0
  245. package/src/v3/parameters.ts +524 -0
  246. package/src/v3/protocol.ts +148 -0
  247. package/src/v3/queries.ts +190 -0
  248. package/src/v3/segments.ts +374 -0
  249. package/src/v3/syntax.ts +369 -0
@@ -0,0 +1,524 @@
1
+ // Internal to the package, and named after the FinTS specification. Documentation is where the meaning is not evident.
2
+
3
+ import { Account } from '../core/account.js';
4
+ import type { Balance } from '../core/balance.js';
5
+ import { BankInfo } from '../core/bank-info.js';
6
+ import { FinTsProtocolError } from '../core/errors.js';
7
+ import { TanMethod, tanMediumRequirementOf } from '../tan/tan-models.js';
8
+ import { base64Decode, base64Encode, concatBytes } from '../util/bytes.js';
9
+ import { nonBlank, tryParseInt } from '../util/text.js';
10
+ import { AccountExtension } from './account-extension.js';
11
+ import { parseSegments, type Segment } from './syntax.js';
12
+
13
+ /**
14
+ * A two-step TAN method as HITANS describes it: whether the client can run it, and the HITANS version, which is the
15
+ * version of the HKTAN segments the method is used with.
16
+ */
17
+ interface OfferedMethod {
18
+ readonly method: TanMethod;
19
+ readonly runnable: boolean;
20
+ readonly version: number;
21
+ }
22
+
23
+ /** Positions of the data elements in the header of the bank parameter data (HIBPA), after the segment header. */
24
+ const Hibpa = {
25
+ version: 1,
26
+ /** Country code, then bank code. */
27
+ bankId: 2,
28
+ bankName: 3,
29
+ languages: 5,
30
+ fintsVersions: 6,
31
+ maxMessageSize: 7,
32
+ minTimeout: 8,
33
+ maxTimeout: 9,
34
+ } as const;
35
+
36
+ /**
37
+ * The data element of every parameter segment (`HI…S`) that holds its specific parameters, after the maximum number of
38
+ * orders, the minimum number of signatures and the security class.
39
+ */
40
+ const parameterGroup = 4;
41
+
42
+ /** The standard language in the communication access (HIKOM). */
43
+ const hikomStandardLanguage = 2;
44
+
45
+ function defined<T>(values: readonly (T | undefined)[]): T[] {
46
+ return values.filter((v): v is T => v !== undefined);
47
+ }
48
+
49
+ /** `bpd`, or `FinTsProtocolError` if the bank has not sent its parameter data yet. */
50
+ export function requireBankParameters(bpd: Bpd3 | undefined): Bpd3 {
51
+ if (bpd === undefined) throw new FinTsProtocolError('No bank parameter data available');
52
+ return bpd;
53
+ }
54
+
55
+ /** FinTS 3.0 bank parameter data (BPD): HIBPA and the parameter segments (`HI…S`), parsed once. */
56
+ export class Bpd3 {
57
+ readonly bankInfo: BankInfo;
58
+
59
+ /** The two-step TAN methods of HITANS versions 6 and 7, by security function. */
60
+ private readonly offeredMethods: ReadonlyMap<string, OfferedMethod>;
61
+
62
+ /** Two-step TAN methods the bank offers and the client can run. */
63
+ readonly tanMethods: readonly TanMethod[];
64
+
65
+ /** Security functions of the two-step methods the bank offers in a form the client cannot run. */
66
+ readonly unsupportedTanMethods: readonly string[];
67
+ readonly pinTan: PinTanParameters;
68
+
69
+ /** camt formats the bank offers for HKCAZ. */
70
+ readonly camtFormats: readonly string[];
71
+
72
+ /** Versions of each parameter segment, by segment type. */
73
+ private readonly versions: ReadonlyMap<string, ReadonlySet<number>>;
74
+
75
+ /**
76
+ * The dialog language HKVVB names: the bank's standard language (HIKOM), else the first it supports (HIBPA). Only
77
+ * these may be sent once the parameter data are known; `0`, the default, only before (Formals C.3.1.3).
78
+ */
79
+ readonly dialogLanguage: number;
80
+
81
+ private encodedValue: string | undefined;
82
+
83
+ private constructor(
84
+ readonly segments: readonly Segment[],
85
+ hibpa: Segment,
86
+ fallbackBankCode: string,
87
+ ) {
88
+ this.bankInfo = new BankInfo({
89
+ bankCode: hibpa.text(Hibpa.bankId, 1) ?? fallbackBankCode,
90
+ name: hibpa.text(Hibpa.bankName),
91
+ bpdVersion: hibpa.integer(Hibpa.version) ?? 0,
92
+ languages: defined(hibpa.texts(Hibpa.languages)),
93
+ supportedVersions: defined(hibpa.texts(Hibpa.fintsVersions)),
94
+ maxMessageSize: hibpa.integer(Hibpa.maxMessageSize),
95
+ minTimeout: hibpa.integer(Hibpa.minTimeout),
96
+ maxTimeout: hibpa.integer(Hibpa.maxTimeout),
97
+ });
98
+ this.offeredMethods = parseTanMethods(segments);
99
+ const offered = [...this.offeredMethods.values()];
100
+ this.tanMethods = Object.freeze(offered.filter((o) => o.runnable).map((o) => o.method));
101
+ this.unsupportedTanMethods = Object.freeze(
102
+ offered.filter((o) => !o.runnable).map((o) => o.method.securityFunction),
103
+ );
104
+ this.pinTan = PinTanParameters.parse(segments);
105
+ this.camtFormats = segments
106
+ .filter((s) => s.type === 'HICAZS')
107
+ .flatMap((s) => defined(s.texts(parameterGroup)))
108
+ .filter((format) => format.includes('camt'));
109
+ this.versions = versionsByType(segments);
110
+ this.dialogLanguage = dialogLanguageOf(segments, this.bankInfo);
111
+ }
112
+
113
+ /** The version of the parameter data, which the client reports back so that the bank sends newer ones only. */
114
+ get version(): number {
115
+ return this.bankInfo.bpdVersion;
116
+ }
117
+
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
+ /** The BPD in `all`, or `undefined` if they contain no HIBPA segment. */
124
+ static fromSegments(all: readonly Segment[], fallbackBankCode: string): Bpd3 | undefined {
125
+ const segments = all.filter(isParameterSegment);
126
+ const hibpa = segments.find((s) => s.type === 'HIBPA');
127
+ return hibpa === undefined ? undefined : new Bpd3(segments, hibpa, fallbackBankCode);
128
+ }
129
+
130
+ /** The parameter segment type of a business transaction: `HKSAL` → `HISALS`. */
131
+ static parameterName(transaction: string): string {
132
+ return `HI${transaction.substring(2)}S`;
133
+ }
134
+
135
+ /** The highest version of `transaction` that the bank offers and the client `implemented`. */
136
+ bestVersion(transaction: string, implemented: ReadonlySet<number>): number | undefined {
137
+ let best: number | undefined;
138
+ for (const version of this.versions.get(Bpd3.parameterName(transaction)) ?? []) {
139
+ if (implemented.has(version) && (best === undefined || version > best)) best = version;
140
+ }
141
+ return best;
142
+ }
143
+
144
+ /** The version of the HKTAN segments that go with `method`; `undefined` for a method the bank does not offer. */
145
+ tanVersion(method: TanMethod): number | undefined {
146
+ return this.offeredMethods.get(method.securityFunction)?.version;
147
+ }
148
+ }
149
+
150
+ function dialogLanguageOf(segments: readonly Segment[], bankInfo: BankInfo): number {
151
+ const languages = new Set([1, 2, 3]);
152
+ const standard = segments.find((s) => s.type === 'HIKOM')?.integer(hikomStandardLanguage);
153
+ if (standard !== undefined && languages.has(standard)) return standard;
154
+ return bankInfo.languages.map((l) => tryParseInt(l)).find((l) => l !== undefined && languages.has(l)) ?? 0;
155
+ }
156
+
157
+ function isParameterSegment(s: Segment): boolean {
158
+ const type = s.type;
159
+ if (['HIBPA', 'HIKOM', 'HISHV', 'HIKPV'].includes(type)) return true;
160
+ if (type === 'HIRMS' || type === 'HIRMG') return false;
161
+ return type.length >= 5 && (type.startsWith('HI') || type.startsWith('DI')) && type.endsWith('S');
162
+ }
163
+
164
+ function versionsByType(segments: readonly Segment[]): Map<string, Set<number>> {
165
+ const versions = new Map<string, Set<number>>();
166
+ for (const s of segments) {
167
+ let set = versions.get(s.type);
168
+ if (set === undefined) versions.set(s.type, (set = new Set()));
169
+ set.add(s.version);
170
+ }
171
+ return versions;
172
+ }
173
+
174
+ /** Parses the two-step TAN methods from HITANS versions 6 and 7; for a method offered in several, the highest wins. */
175
+ function parseTanMethods(segments: readonly Segment[]): Map<string, OfferedMethod> {
176
+ const byCode = new Map<string, OfferedMethod>();
177
+ for (const s of segments.filter((s) => s.type === 'HITANS')) {
178
+ for (const offered of parseHitans(s)) {
179
+ const existing = byCode.get(offered.method.securityFunction);
180
+ if (existing === undefined || offered.version > existing.version) {
181
+ byCode.set(offered.method.securityFunction, offered);
182
+ }
183
+ }
184
+ }
185
+ return byCode;
186
+ }
187
+
188
+ /** The fields describing one TAN method in HITANS, in order; version 6 ends with `activeMediaCount`. */
189
+ const HitansField = {
190
+ securityFunction: 0,
191
+ processVariant: 1,
192
+ technicalId: 2,
193
+ dkMethod: 3,
194
+ dkVersion: 4,
195
+ name: 5,
196
+ maxTanLength: 6,
197
+ tanFormat: 7,
198
+ returnValueText: 8,
199
+ /** "Maximale Länge des Rückgabewertes": the return value is the challenge. */
200
+ maxChallengeLength: 9,
201
+ multipleTans: 10,
202
+ tanTimeAndDialogRelation: 11,
203
+ orderCancellation: 12,
204
+ smsChargeAccountRequired: 13,
205
+ orderingAccountRequired: 14,
206
+ challengeClassRequired: 15,
207
+ structuredChallenge: 16,
208
+ initializationMode: 17,
209
+ mediumNameRequired: 18,
210
+ hhdUcRequired: 19,
211
+ activeMediaCount: 20,
212
+ maxStatusPolls: 21,
213
+ firstPollDelay: 22,
214
+ pollInterval: 23,
215
+ manualConfirmation: 24,
216
+ automaticPolling: 25,
217
+ } as const;
218
+
219
+ function parseHitans(s: Segment): OfferedMethod[] {
220
+ const version = s.version;
221
+ if (version !== 6 && version !== 7) return [];
222
+ // Version 7 adds the fields for decoupled approval.
223
+ const fieldsPerMethod = version === 6 ? HitansField.activeMediaCount + 1 : HitansField.automaticPolling + 1;
224
+ const all = s.texts(parameterGroup);
225
+ const methods: OfferedMethod[] = [];
226
+ // The group starts with three general fields, then lists the methods.
227
+ for (let start = 3; start < all.length; start += fieldsPerMethod) {
228
+ const fields = all.slice(start, start + fieldsPerMethod);
229
+ const field = (f: number): string | undefined => fields[f];
230
+ const number = (f: number): number | undefined => tryParseInt(field(f));
231
+ const code = field(HitansField.securityFunction);
232
+ if (code === undefined) continue;
233
+ // Process variant 1 needs the order hash and another message flow; the client runs variant 2, the one of PSD2
234
+ // strong customer authentication. An SMS charge account or an HHD_UC answer, where required, it cannot send.
235
+ const runnable =
236
+ number(HitansField.processVariant) !== 1 &&
237
+ field(HitansField.smsChargeAccountRequired) !== '2' &&
238
+ field(HitansField.hhdUcRequired) !== 'J';
239
+ const dkMethod = field(HitansField.dkMethod);
240
+ const decoupledFields = version === 7;
241
+ const method = new TanMethod({
242
+ securityFunction: code,
243
+ technicalId: field(HitansField.technicalId),
244
+ dkMethod,
245
+ dkVersion: field(HitansField.dkVersion),
246
+ name: field(HitansField.name) ?? code,
247
+ maxTanLength: number(HitansField.maxTanLength),
248
+ numericTan: field(HitansField.tanFormat) !== '2',
249
+ returnValueText: field(HitansField.returnValueText),
250
+ maxChallengeLength: number(HitansField.maxChallengeLength),
251
+ structuredChallenge: field(HitansField.structuredChallenge) === 'J',
252
+ mediumRequirement: tanMediumRequirementOf(field(HitansField.mediumNameRequired)),
253
+ activeMediaCount: number(HitansField.activeMediaCount),
254
+ decoupled: decoupledFields && dkMethod !== undefined && dkMethod.toLowerCase().startsWith('decoupled'),
255
+ decoupledMaxPolls: decoupledFields ? number(HitansField.maxStatusPolls) : undefined,
256
+ decoupledFirstPollDelaySeconds: decoupledFields ? number(HitansField.firstPollDelay) : undefined,
257
+ decoupledPollIntervalSeconds: decoupledFields ? number(HitansField.pollInterval) : undefined,
258
+ decoupledManualConfirmation: !decoupledFields || field(HitansField.manualConfirmation) !== 'N',
259
+ decoupledAutomaticPolling: !decoupledFields || field(HitansField.automaticPolling) !== 'N',
260
+ });
261
+ methods.push({ method, runnable, version });
262
+ }
263
+ return methods;
264
+ }
265
+
266
+ /**
267
+ * PIN/TAN parameters (HIPINS): the PIN length, which business transactions PIN/TAN allows, and which of them need a
268
+ * TAN.
269
+ */
270
+ export class PinTanParameters {
271
+ private constructor(
272
+ private readonly tanRequired: ReadonlyMap<string, boolean>,
273
+ /** The shortest and longest PIN the bank takes. */
274
+ readonly pinLength: { min: number | undefined; max: number | undefined },
275
+ ) {}
276
+
277
+ /** Reads the HIPINS segment of the highest version. */
278
+ 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 });
284
+ const fields = hipins.texts(parameterGroup);
285
+ const tanRequired = new Map<string, boolean>();
286
+ // Five general fields, the PIN lengths first; then pairs of segment type and TAN flag.
287
+ for (let i = 5; i + 1 < fields.length; i += 2) {
288
+ const type = fields[i];
289
+ if (type !== undefined) tanRequired.set(type, fields[i + 1] === 'J');
290
+ }
291
+ return new PinTanParameters(tanRequired, { min: tryParseInt(fields[0]), max: tryParseInt(fields[1]) });
292
+ }
293
+
294
+ /** Whether the bank wants a TAN for the business transaction of `segmentType`, such as `HKKAZ`. */
295
+ requiresTan(segmentType: string): boolean {
296
+ return this.tanRequired.get(segmentType) ?? false;
297
+ }
298
+
299
+ /** Whether PIN/TAN allows `segmentType` at all: HIPINS lists it, or lists no business transactions (PIN/TAN B.8.1). */
300
+ allowsWithPinTan(segmentType: string): boolean {
301
+ return this.tanRequired.size === 0 || this.tanRequired.has(segmentType);
302
+ }
303
+ }
304
+
305
+ interface UpdEntry {
306
+ readonly account: Account;
307
+ readonly extension: AccountExtension | undefined;
308
+ }
309
+
310
+ /** The user ID (Benutzerkennung), the version and the usage ("UPD-Verwendung") of the user parameter data in HIUPA. */
311
+ const hiupaUserId = 1;
312
+ const hiupaVersion = 2;
313
+ const hiupaUsage = 3;
314
+
315
+ /** Business transaction codes such as `HKSAL` or `DKPAE`. */
316
+ const transactionCode = /^[A-Z]{4,6}$/;
317
+
318
+ /** FinTS 3.0 user parameter data (UPD): HIUPA and the accounts (HIUPD), parsed once. */
319
+ export class Upd3 {
320
+ /** The user the bank sent them for; `undefined` where it names none. */
321
+ readonly userId: string | undefined;
322
+
323
+ /** The version of the parameter data, which the client reports back so that the bank sends newer ones only. */
324
+ readonly version: number;
325
+
326
+ /**
327
+ * Whether the lists of permitted transactions are complete: the bank permits nothing they leave out ("UPD-Verwendung"
328
+ * 0).
329
+ */
330
+ readonly listsAllTransactions: boolean;
331
+
332
+ /** The transactions any entry lists as permitted, with or without an account. */
333
+ readonly listedTransactions: ReadonlySet<string>;
334
+
335
+ /** The accounts with the extension each carries, where it does. */
336
+ private readonly entries: readonly UpdEntry[];
337
+
338
+ /** The accounts the user may access. */
339
+ readonly accounts: readonly Account[];
340
+
341
+ private encodedValue: string | undefined;
342
+
343
+ /** `listsAll` tells whether HIUPA declares the lists of permitted transactions complete ("UPD-Verwendung" 0). */
344
+ private constructor(
345
+ readonly segments: readonly Segment[],
346
+ hiupa: Segment,
347
+ listsAll: boolean,
348
+ defaultBankCode: string,
349
+ defaultCountry: string,
350
+ ) {
351
+ const hiupds = segments.filter((s) => s.type === 'HIUPD');
352
+ this.userId = hiupa.text(hiupaUserId)?.trim();
353
+ this.version = hiupa.integer(hiupaVersion) ?? 0;
354
+ this.listsAllTransactions = listsAll;
355
+ this.listedTransactions = new Set(hiupds.flatMap(transactionsOf));
356
+ this.entries = Object.freeze(
357
+ hiupds.map((s) => entryOf(s, defaultBankCode, defaultCountry, listsAll)).filter((e) => e !== undefined),
358
+ );
359
+ 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));
365
+ }
366
+
367
+ /**
368
+ * The balance of `account` the user parameter data carry, where it holds all a balance query would deliver, so that
369
+ * the client asks for none (Formals E.3.1, F.3.2); `undefined` otherwise.
370
+ */
371
+ completeBalance(account: Account): Balance | undefined {
372
+ return this.entries.find((e) => e.account.matches(account))?.extension?.completeBalance(account);
373
+ }
374
+
375
+ /** The UPD in `all`, or `undefined` if they contain no HIUPA segment. */
376
+ static fromSegments(all: readonly Segment[], defaultBankCode: string, defaultCountry: string): Upd3 | undefined {
377
+ const segments = all.filter((s) => s.type === 'HIUPA' || s.type === 'HIUPD');
378
+ const hiupa = segments.find((s) => s.type === 'HIUPA');
379
+ if (hiupa === undefined) return undefined;
380
+ return new Upd3(segments, hiupa, hiupa.text(hiupaUsage) === '0', defaultBankCode, defaultCountry);
381
+ }
382
+ }
383
+
384
+ /**
385
+ * The data element where the permitted transactions start, by HIUPD version: version 6 inserts the IBAN, version 5 the
386
+ * account type.
387
+ */
388
+ function allowedFrom(version: number): number {
389
+ return version >= 6 ? 10 : version === 5 ? 9 : 8;
390
+ }
391
+
392
+ /** The permitted transactions of the HIUPD `s`. */
393
+ function transactionsOf(s: Segment): string[] {
394
+ const codes: string[] = [];
395
+ for (let i = allowedFrom(s.version); i < s.length; i++) {
396
+ const code = s.text(i, 0);
397
+ if (code !== undefined && transactionCode.test(code)) codes.push(code);
398
+ }
399
+ return codes;
400
+ }
401
+
402
+ /**
403
+ * The account extension of `s` (version 6, "Erweiterung, kontobezogen"): the JSON object behind the permitted
404
+ * transactions, whose count varies. A bank that leaves its colons unescaped splits it into group elements, which are
405
+ * joined again.
406
+ */
407
+ function extensionOf(s: Segment): AccountExtension | undefined {
408
+ if (s.version < 6) return undefined;
409
+ 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);
415
+ }
416
+ return undefined;
417
+ }
418
+
419
+ function entryOf(s: Segment, defaultBankCode: string, defaultCountry: string, listsAll: boolean): UpdEntry | undefined {
420
+ const account = accountOf(s, defaultBankCode, defaultCountry, listsAll);
421
+ if (account === undefined) return undefined;
422
+ const extension = extensionOf(s);
423
+ return {
424
+ account: extension === undefined ? account : account.copyWith({ bic: extension.bic, dataStatus: extension.status }),
425
+ extension,
426
+ };
427
+ }
428
+
429
+ function accountOf(
430
+ s: Segment,
431
+ defaultBankCode: string,
432
+ defaultCountry: string,
433
+ listsAll: boolean,
434
+ ): Account | undefined {
435
+ 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);
438
+ const iban = version >= 6 ? s.text(2) : undefined;
439
+ if (number === undefined && iban === undefined) return undefined;
440
+ // Field positions by HIUPD version: version 6 inserts the IBAN, version 5 the account type.
441
+ const fields =
442
+ version >= 6
443
+ ? { customer: 3, type: 4, currency: 5, name1: 6, name2: 7, product: 8 }
444
+ : version === 5
445
+ ? { customer: 2, type: 3, currency: 4, name1: 5, name2: 6, product: 7 }
446
+ : { customer: 2, type: undefined, currency: 3, name1: 4, name2: 5, product: 6 };
447
+ return new Account({
448
+ accountNumber: number,
449
+ subAccount,
450
+ countryCode: country ?? defaultCountry,
451
+ bankCode: bank ?? defaultBankCode,
452
+ iban,
453
+ customerId: s.text(fields.customer),
454
+ typeCode: fields.type === undefined ? undefined : s.integer(fields.type),
455
+ currency: s.text(fields.currency),
456
+ ownerName: nonBlank(defined([s.text(fields.name1), s.text(fields.name2)]).join(' ')),
457
+ productName: s.text(fields.product),
458
+ allowedTransactions: transactionsOf(s),
459
+ allowedTransactionsComplete: listsAll,
460
+ });
461
+ }
462
+
463
+ /**
464
+ * The version of `transaction` to send, for `account` where it concerns one: the highest the bank offers and the client
465
+ * `implemented`. `undefined` where there is none, or the parameter data do not permit the transaction
466
+ * (`permitsTransaction`).
467
+ */
468
+ export function offeredVersion(
469
+ bpd: Bpd3 | undefined,
470
+ upd: Upd3 | undefined,
471
+ transaction: string,
472
+ implemented: ReadonlySet<number>,
473
+ options: { account?: Account; requiresEntry?: boolean },
474
+ ): number | undefined {
475
+ return permitsTransaction(bpd, upd, transaction, options) ? bpd?.bestVersion(transaction, implemented) : undefined;
476
+ }
477
+
478
+ /**
479
+ * Whether the parameter data let the user send `transaction`, for `account` where it concerns one: the bank allows it
480
+ * with PIN/TAN, and the user parameter data permit it (Formals E.1, F.3.4; PIN/TAN B.1, B.8.1).
481
+ *
482
+ * - `account`, which the session has taken as the bank knows it (`SepaAccounts.complete`), permits what it lists, and
483
+ * anything else only where its list is incomplete ("UPD-Verwendung" 1). One only the SEPA account list names lists
484
+ * nothing, and so permits anything; where the lists are complete, the session refuses one the bank names nowhere.
485
+ * - A transaction without account, such as the TAN media list, is permitted where some entry lists it or the lists are
486
+ * incomplete.
487
+ * - One that `requiresEntry`, the SEPA account list, must be listed even where the lists are incomplete (Formals F.3.4),
488
+ * unless they list nothing at all, which tells nothing.
489
+ */
490
+ function permitsTransaction(
491
+ bpd: Bpd3 | undefined,
492
+ upd: Upd3 | undefined,
493
+ transaction: string,
494
+ options: { account?: Account; requiresEntry?: boolean },
495
+ ): boolean {
496
+ if (bpd !== undefined && !bpd.pinTan.allowsWithPinTan(transaction)) return false;
497
+ if (upd === undefined) return true;
498
+ if (options.account !== undefined) return allows(options.account, transaction);
499
+ const listed = upd.listedTransactions;
500
+ if (listed.has(transaction)) return true;
501
+ return !upd.listsAllTransactions && (!(options.requiresEntry ?? false) || listed.size === 0);
502
+ }
503
+
504
+ /** Whether the bank lists `transaction`, such as `HKCAZ`, as permitted for `account`. */
505
+ export function lists(account: Account, transaction: string): boolean {
506
+ return account.allowedTransactions.includes(transaction);
507
+ }
508
+
509
+ /**
510
+ * Whether the bank may permit `transaction` on `account`: it lists it, or its list is not complete
511
+ * (`Account.allowedTransactionsComplete`), so that only the bank can tell.
512
+ */
513
+ function allows(account: Account, transaction: string): boolean {
514
+ return lists(account, transaction) || !account.allowedTransactionsComplete;
515
+ }
516
+
517
+ /** Serializes parameter segments for the saved state. */
518
+ function encodeSegments(segments: readonly Segment[]): string {
519
+ return base64Encode(concatBytes(segments.map((s) => s.encode())));
520
+ }
521
+
522
+ export function decodeSegments(data: string): Segment[] {
523
+ return parseSegments(base64Decode(data));
524
+ }
@@ -0,0 +1,148 @@
1
+ // Internal to the package, and named after the FinTS specification. Documentation is where the meaning is not evident.
2
+
3
+ import type { MessageLogger } from '../core/client.js';
4
+ import { requirePinLength, type FinTsConfig } from '../core/config.js';
5
+ import { clientClosedError } from '../core/errors.js';
6
+ import type { FinTsTransport } from '../core/transport.js';
7
+ import { decodeBankText } from '../util/text.js';
8
+ import { BankConnection, checkMessageSize } from './bank-connection.js';
9
+ import { buildMessage, decodeTransport, encodeTransport } from './message.js';
10
+ import type { ParameterStore } from './parameter-store.js';
11
+ import { Segments } from './segments.js';
12
+ import type { SegmentSpec } from './syntax.js';
13
+
14
+ /** Customer ID the specification reserves for anonymous dialogs. */
15
+ const anonymousCustomerId = '9999999999';
16
+
17
+ /** How a message is signed: the security function of the TAN method and the customer system ID. */
18
+ export interface Signing3 {
19
+ readonly securityFunction: string;
20
+ readonly systemId: string;
21
+ }
22
+
23
+ /** Hands out the PIN for each message the protocol signs, and so decides whether one may go out. */
24
+ export interface PinGuard {
25
+ /** The PIN to sign a message with; throws instead where none may go out. */
26
+ readonly pin: string;
27
+
28
+ /** Counts a login signed with the PIN, the first message of a dialog, before it goes out. */
29
+ loginSent(): void;
30
+ }
31
+
32
+ /** Whether `segments` are those of a dialog end, the one message a closing client still sends. */
33
+ function isDialogEnd(segments: readonly SegmentSpec[]): boolean {
34
+ return segments.length === 1 && segments[0]!.type === 'HKEND';
35
+ }
36
+
37
+ /**
38
+ * Builds, signs and posts FinTS 3.0 messages (HBCI segment syntax, PIN/TAN security) for the user `parameters.config`
39
+ * names, with the parameter data and IDs `parameters` keeps. Every message signed with the PIN takes it from the
40
+ * `PinGuard` (`post`), which keeps it from going out after a refused login.
41
+ */
42
+ export class FinTs3Protocol {
43
+ private readonly config: FinTsConfig;
44
+ private readonly connection: BankConnection;
45
+
46
+ /** Whether the client is closing, so that only dialog ends go out (`beginClose`). */
47
+ private closing = false;
48
+
49
+ constructor(
50
+ readonly parameters: ParameterStore,
51
+ private readonly guard: PinGuard,
52
+ transport?: FinTsTransport,
53
+ logger?: MessageLogger,
54
+ ) {
55
+ this.config = parameters.config;
56
+ this.connection = new BankConnection(this.config.url, transport, logger);
57
+ }
58
+
59
+ /** Identification (HKIDN) and processing preparation (HKVVB), which open every dialog. */
60
+ initSegments(systemId: string, anonymous = false): SegmentSpec[] {
61
+ return [
62
+ Segments.identification({
63
+ countryCode: this.config.countryCode,
64
+ bankCode: this.config.bankCode,
65
+ customerId: anonymous ? anonymousCustomerId : this.config.customerId,
66
+ systemId,
67
+ systemIdRequired: !anonymous,
68
+ }),
69
+ Segments.processingPreparation({
70
+ bpdVersion: this.parameters.reportedBpdVersion,
71
+ updVersion: anonymous ? 0 : this.parameters.reportedUpdVersion,
72
+ language: this.parameters.bankParameters?.dialogLanguage ?? 0,
73
+ productId: this.config.productId,
74
+ productVersion: this.config.productVersion,
75
+ }),
76
+ ];
77
+ }
78
+
79
+ /**
80
+ * Sends `segments` as message `number` of the dialog `dialogId` and returns the bank's answer, not yet parsed.
81
+ *
82
+ * With `signing` the message is signed with the PIN, which `PinGuard.pin` hands out, and the `tan`; without, it
83
+ * goes out unsigned, as in an anonymous dialog.
84
+ */
85
+ async post(message: {
86
+ dialogId: string;
87
+ number: number;
88
+ segments: readonly SegmentSpec[];
89
+ signing: Signing3 | undefined;
90
+ tan: string | undefined;
91
+ }): Promise<Uint8Array> {
92
+ if (this.closing && !isDialogEnd(message.segments)) throw clientClosedError();
93
+ const signing = message.signing;
94
+ // The first message of a signed dialog logs in: from here on the bank may count the PIN.
95
+ const login = signing !== undefined && message.number === 1;
96
+ const outgoing = buildMessage({
97
+ dialogId: message.dialogId,
98
+ messageNumber: message.number,
99
+ segments: message.segments,
100
+ security:
101
+ signing === undefined
102
+ ? undefined
103
+ : {
104
+ countryCode: this.config.countryCode,
105
+ bankCode: this.config.bankCode,
106
+ userId: this.config.userId,
107
+ systemId: signing.systemId,
108
+ pin: login ? this.pinForLogin() : this.guard.pin,
109
+ securityFunction: signing.securityFunction,
110
+ },
111
+ tan: message.tan,
112
+ });
113
+ checkMessageSize(outgoing.bytes.length, this.parameters.bankParameters?.bankInfo.maxMessageSize);
114
+ this.connection.log('outgoing', () => outgoing.masked);
115
+ if (login) this.guard.loginSent();
116
+ const bytes = decodeTransport(await this.connection.post(encodeTransport(outgoing.bytes), 'text/plain'));
117
+ this.connection.log('incoming', () => decodeBankText(bytes));
118
+ return bytes;
119
+ }
120
+
121
+ /**
122
+ * The PIN to sign a login with, the first message of a dialog.
123
+ *
124
+ * Throws instead where the guard holds the PIN back (`PinGuard.pin`), or if the bank parameters rule out the PIN's
125
+ * length: the bank would count such a PIN as a failed attempt. The later messages of a dialog, its end included, take
126
+ * the PIN the bank has accepted, whatever the parameter data it delivered since say.
127
+ */
128
+ private pinForLogin(): string {
129
+ const pin = this.guard.pin;
130
+ const length = this.parameters.bankParameters?.pinTan.pinLength;
131
+ requirePinLength(pin, length?.min, length?.max);
132
+ return pin;
133
+ }
134
+
135
+ /**
136
+ * Aborts the request on its way, and sends nothing but dialog ends from now on: any other message fails with
137
+ * `FinTsStateError` before it goes out, so that a call under way stops at its next one. The client calls it as soon as
138
+ * it is closed, and `close` once it has ended its dialog.
139
+ */
140
+ beginClose(): void {
141
+ this.closing = true;
142
+ this.connection.abortRequests();
143
+ }
144
+
145
+ close(): void {
146
+ this.connection.close();
147
+ }
148
+ }