@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.
- package/CHANGELOG.md +152 -0
- package/LICENSE +28 -0
- package/README.md +246 -2
- package/dist/client.d.ts +48 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +199 -0
- package/dist/client.js.map +1 -0
- package/dist/core/account.d.ts +168 -0
- package/dist/core/account.d.ts.map +1 -0
- package/dist/core/account.js +245 -0
- package/dist/core/account.js.map +1 -0
- package/dist/core/amount.d.ts +63 -0
- package/dist/core/amount.d.ts.map +1 -0
- package/dist/core/amount.js +192 -0
- package/dist/core/amount.js.map +1 -0
- package/dist/core/balance.d.ts +55 -0
- package/dist/core/balance.d.ts.map +1 -0
- package/dist/core/balance.js +54 -0
- package/dist/core/balance.js.map +1 -0
- package/dist/core/bank-info.d.ts +49 -0
- package/dist/core/bank-info.d.ts.map +1 -0
- package/dist/core/bank-info.js +52 -0
- package/dist/core/bank-info.js.map +1 -0
- package/dist/core/client.d.ts +161 -0
- package/dist/core/client.d.ts.map +1 -0
- package/dist/core/client.js +2 -0
- package/dist/core/client.js.map +1 -0
- package/dist/core/config.d.ts +80 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/config.js +153 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/errors.d.ts +153 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +247 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/feedback.d.ts +72 -0
- package/dist/core/feedback.d.ts.map +1 -0
- package/dist/core/feedback.js +127 -0
- package/dist/core/feedback.js.map +1 -0
- package/dist/core/transaction.d.ts +103 -0
- package/dist/core/transaction.d.ts.map +1 -0
- package/dist/core/transaction.js +96 -0
- package/dist/core/transaction.js.map +1 -0
- package/dist/core/transport.d.ts +64 -0
- package/dist/core/transport.d.ts.map +1 -0
- package/dist/core/transport.js +149 -0
- package/dist/core/transport.js.map +1 -0
- package/dist/formats/camt.d.ts +32 -0
- package/dist/formats/camt.d.ts.map +1 -0
- package/dist/formats/camt.js +242 -0
- package/dist/formats/camt.js.map +1 -0
- package/dist/formats/mt940.d.ts +54 -0
- package/dist/formats/mt940.d.ts.map +1 -0
- package/dist/formats/mt940.js +376 -0
- package/dist/formats/mt940.js.map +1 -0
- package/dist/formats/readers.d.ts +15 -0
- package/dist/formats/readers.d.ts.map +1 -0
- package/dist/formats/readers.js +42 -0
- package/dist/formats/readers.js.map +1 -0
- package/dist/formats/record-budget.d.ts +7 -0
- package/dist/formats/record-budget.d.ts.map +1 -0
- package/dist/formats/record-budget.js +17 -0
- package/dist/formats/record-budget.js.map +1 -0
- package/dist/formats/sepa-purpose.d.ts +28 -0
- package/dist/formats/sepa-purpose.d.ts.map +1 -0
- package/dist/formats/sepa-purpose.js +89 -0
- package/dist/formats/sepa-purpose.js.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/session/app-approval.d.ts +66 -0
- package/dist/session/app-approval.d.ts.map +1 -0
- package/dist/session/app-approval.js +226 -0
- package/dist/session/app-approval.js.map +1 -0
- package/dist/session/dialog-runner.d.ts +31 -0
- package/dist/session/dialog-runner.d.ts.map +1 -0
- package/dist/session/dialog-runner.js +111 -0
- package/dist/session/dialog-runner.js.map +1 -0
- package/dist/session/login-guard.d.ts +78 -0
- package/dist/session/login-guard.d.ts.map +1 -0
- package/dist/session/login-guard.js +181 -0
- package/dist/session/login-guard.js.map +1 -0
- package/dist/session/sepa-accounts.d.ts +40 -0
- package/dist/session/sepa-accounts.d.ts.map +1 -0
- package/dist/session/sepa-accounts.js +112 -0
- package/dist/session/sepa-accounts.js.map +1 -0
- package/dist/session/session-setup.d.ts +80 -0
- package/dist/session/session-setup.d.ts.map +1 -0
- package/dist/session/session-setup.js +182 -0
- package/dist/session/session-setup.js.map +1 -0
- package/dist/session/session-state.d.ts +50 -0
- package/dist/session/session-state.d.ts.map +1 -0
- package/dist/session/session-state.js +182 -0
- package/dist/session/session-state.js.map +1 -0
- package/dist/session/signed-dialog.d.ts +77 -0
- package/dist/session/signed-dialog.d.ts.map +1 -0
- package/dist/session/signed-dialog.js +170 -0
- package/dist/session/signed-dialog.js.map +1 -0
- package/dist/session/tan-selection.d.ts +96 -0
- package/dist/session/tan-selection.d.ts.map +1 -0
- package/dist/session/tan-selection.js +189 -0
- package/dist/session/tan-selection.js.map +1 -0
- package/dist/session/user-prompts.d.ts +65 -0
- package/dist/session/user-prompts.d.ts.map +1 -0
- package/dist/session/user-prompts.js +184 -0
- package/dist/session/user-prompts.js.map +1 -0
- package/dist/tan/challenge.d.ts +135 -0
- package/dist/tan/challenge.d.ts.map +1 -0
- package/dist/tan/challenge.js +184 -0
- package/dist/tan/challenge.js.map +1 -0
- package/dist/tan/flicker.d.ts +42 -0
- package/dist/tan/flicker.d.ts.map +1 -0
- package/dist/tan/flicker.js +296 -0
- package/dist/tan/flicker.js.map +1 -0
- package/dist/tan/tan-image.d.ts +22 -0
- package/dist/tan/tan-image.d.ts.map +1 -0
- package/dist/tan/tan-image.js +43 -0
- package/dist/tan/tan-image.js.map +1 -0
- package/dist/tan/tan-models.d.ts +149 -0
- package/dist/tan/tan-models.d.ts.map +1 -0
- package/dist/tan/tan-models.js +153 -0
- package/dist/tan/tan-models.js.map +1 -0
- package/dist/util/async.d.ts +22 -0
- package/dist/util/async.d.ts.map +1 -0
- package/dist/util/async.js +45 -0
- package/dist/util/async.js.map +1 -0
- package/dist/util/bytes.d.ts +22 -0
- package/dist/util/bytes.d.ts.map +1 -0
- package/dist/util/bytes.js +128 -0
- package/dist/util/bytes.js.map +1 -0
- package/dist/util/call-context.d.ts +12 -0
- package/dist/util/call-context.d.ts.map +1 -0
- package/dist/util/call-context.js +30 -0
- package/dist/util/call-context.js.map +1 -0
- package/dist/util/dates.d.ts +55 -0
- package/dist/util/dates.d.ts.map +1 -0
- package/dist/util/dates.js +189 -0
- package/dist/util/dates.js.map +1 -0
- package/dist/util/errors.d.ts +17 -0
- package/dist/util/errors.d.ts.map +1 -0
- package/dist/util/errors.js +24 -0
- package/dist/util/errors.js.map +1 -0
- package/dist/util/immutable-map.d.ts +16 -0
- package/dist/util/immutable-map.d.ts.map +1 -0
- package/dist/util/immutable-map.js +38 -0
- package/dist/util/immutable-map.js.map +1 -0
- package/dist/util/json.d.ts +17 -0
- package/dist/util/json.d.ts.map +1 -0
- package/dist/util/json.js +62 -0
- package/dist/util/json.js.map +1 -0
- package/dist/util/text.d.ts +37 -0
- package/dist/util/text.d.ts.map +1 -0
- package/dist/util/text.js +107 -0
- package/dist/util/text.js.map +1 -0
- package/dist/util/xml.d.ts +36 -0
- package/dist/util/xml.d.ts.map +1 -0
- package/dist/util/xml.js +194 -0
- package/dist/util/xml.js.map +1 -0
- package/dist/v3/account-extension.d.ts +25 -0
- package/dist/v3/account-extension.d.ts.map +1 -0
- package/dist/v3/account-extension.js +173 -0
- package/dist/v3/account-extension.js.map +1 -0
- package/dist/v3/bank-connection.d.ts +34 -0
- package/dist/v3/bank-connection.d.ts.map +1 -0
- package/dist/v3/bank-connection.js +58 -0
- package/dist/v3/bank-connection.js.map +1 -0
- package/dist/v3/dialog.d.ts +112 -0
- package/dist/v3/dialog.d.ts.map +1 -0
- package/dist/v3/dialog.js +241 -0
- package/dist/v3/dialog.js.map +1 -0
- package/dist/v3/message.d.ts +85 -0
- package/dist/v3/message.d.ts.map +1 -0
- package/dist/v3/message.js +303 -0
- package/dist/v3/message.js.map +1 -0
- package/dist/v3/parameter-store.d.ts +75 -0
- package/dist/v3/parameter-store.d.ts.map +1 -0
- package/dist/v3/parameter-store.js +143 -0
- package/dist/v3/parameter-store.js.map +1 -0
- package/dist/v3/parameters.d.ts +105 -0
- package/dist/v3/parameters.d.ts.map +1 -0
- package/dist/v3/parameters.js +450 -0
- package/dist/v3/parameters.js.map +1 -0
- package/dist/v3/protocol.d.ts +61 -0
- package/dist/v3/protocol.d.ts.map +1 -0
- package/dist/v3/protocol.js +113 -0
- package/dist/v3/protocol.js.map +1 -0
- package/dist/v3/queries.d.ts +51 -0
- package/dist/v3/queries.d.ts.map +1 -0
- package/dist/v3/queries.js +120 -0
- package/dist/v3/queries.js.map +1 -0
- package/dist/v3/segments.d.ts +106 -0
- package/dist/v3/segments.d.ts.map +1 -0
- package/dist/v3/segments.js +325 -0
- package/dist/v3/segments.js.map +1 -0
- package/dist/v3/syntax.d.ts +127 -0
- package/dist/v3/syntax.d.ts.map +1 -0
- package/dist/v3/syntax.js +367 -0
- package/dist/v3/syntax.js.map +1 -0
- package/package.json +67 -4
- package/src/client.ts +252 -0
- package/src/core/account.ts +323 -0
- package/src/core/amount.ts +211 -0
- package/src/core/balance.ts +85 -0
- package/src/core/bank-info.ts +66 -0
- package/src/core/client.ts +186 -0
- package/src/core/config.ts +182 -0
- package/src/core/errors.ts +277 -0
- package/src/core/feedback.ts +140 -0
- package/src/core/transaction.ts +161 -0
- package/src/core/transport.ts +189 -0
- package/src/formats/camt.ts +266 -0
- package/src/formats/mt940.ts +419 -0
- package/src/formats/readers.ts +49 -0
- package/src/formats/record-budget.ts +18 -0
- package/src/formats/sepa-purpose.ts +94 -0
- package/src/index.ts +70 -0
- package/src/session/app-approval.ts +278 -0
- package/src/session/dialog-runner.ts +122 -0
- package/src/session/login-guard.ts +221 -0
- package/src/session/sepa-accounts.ts +115 -0
- package/src/session/session-setup.ts +184 -0
- package/src/session/session-state.ts +204 -0
- package/src/session/signed-dialog.ts +192 -0
- package/src/session/tan-selection.ts +203 -0
- package/src/session/user-prompts.ts +193 -0
- package/src/tan/challenge.ts +261 -0
- package/src/tan/flicker.ts +308 -0
- package/src/tan/tan-image.ts +38 -0
- package/src/tan/tan-models.ts +252 -0
- package/src/util/async.ts +48 -0
- package/src/util/bytes.ts +129 -0
- package/src/util/call-context.ts +41 -0
- package/src/util/dates.ts +210 -0
- package/src/util/errors.ts +27 -0
- package/src/util/immutable-map.ts +47 -0
- package/src/util/json.ts +67 -0
- package/src/util/text.ts +112 -0
- package/src/util/xml.ts +183 -0
- package/src/v3/account-extension.ts +185 -0
- package/src/v3/bank-connection.ts +67 -0
- package/src/v3/dialog.ts +283 -0
- package/src/v3/message.ts +373 -0
- package/src/v3/parameter-store.ts +160 -0
- package/src/v3/parameters.ts +524 -0
- package/src/v3/protocol.ts +148 -0
- package/src/v3/queries.ts +190 -0
- package/src/v3/segments.ts +374 -0
- package/src/v3/syntax.ts +369 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
// Internal to the package, and named after the FinTS specification. Documentation is where the meaning is not evident.
|
|
2
|
+
|
|
3
|
+
import { BankMessage } from '../core/bank-info.js';
|
|
4
|
+
import { FinTsFeedback } from '../core/feedback.js';
|
|
5
|
+
import { TanMethod } from '../tan/tan-models.js';
|
|
6
|
+
import { base64Encode, BytesBuilder, latin1Encode, utf8Decode } from '../util/bytes.js';
|
|
7
|
+
import { formatFintsDate, formatFintsTime } from '../util/dates.js';
|
|
8
|
+
import { FormatError } from '../util/errors.js';
|
|
9
|
+
import { decodeBankText, decodeBase64Lines } from '../util/text.js';
|
|
10
|
+
import {
|
|
11
|
+
Binary,
|
|
12
|
+
dataElementMemory,
|
|
13
|
+
maxDataElements,
|
|
14
|
+
parseSegments,
|
|
15
|
+
type Segment,
|
|
16
|
+
SegmentSpec,
|
|
17
|
+
type SegmentValue,
|
|
18
|
+
Verbatim,
|
|
19
|
+
} from './syntax.js';
|
|
20
|
+
|
|
21
|
+
/** Signature data of a PIN/TAN message. */
|
|
22
|
+
export interface PinTanCredentials {
|
|
23
|
+
readonly countryCode: string;
|
|
24
|
+
readonly bankCode: string;
|
|
25
|
+
readonly userId: string;
|
|
26
|
+
readonly systemId: string;
|
|
27
|
+
readonly pin: string;
|
|
28
|
+
readonly securityFunction: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isTwoStep(c: PinTanCredentials): boolean {
|
|
32
|
+
return c.securityFunction !== TanMethod.oneStep.securityFunction;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Codes of the PIN/TAN security envelope, from the FinTS security specification. PIN/TAN neither hashes, signs nor
|
|
36
|
+
// encrypts, but the envelope must name the algorithms of the chip card procedures all the same.
|
|
37
|
+
|
|
38
|
+
/** Area the signature covers: signature head and data (SHM). */
|
|
39
|
+
const signedArea = 1;
|
|
40
|
+
|
|
41
|
+
/** Role of the security supplier: issuer (ISS). */
|
|
42
|
+
const issuer = 1;
|
|
43
|
+
|
|
44
|
+
/** Security identification: message sender (MS), empty CID, then the customer system ID. */
|
|
45
|
+
function sender(systemId: string): SegmentValue {
|
|
46
|
+
return [1, null, new Verbatim(systemId)];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Security reference number, the same in every message. */
|
|
50
|
+
const securityReference = 1;
|
|
51
|
+
|
|
52
|
+
/** Security time stamp (STS). */
|
|
53
|
+
function timestamp(time: Date): SegmentValue {
|
|
54
|
+
return [1, formatFintsDate(time), formatFintsTime(time)];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Hash algorithm: owner hashing (OHA), mutually agreed algorithm (ZZZ), initialisation value in clear (IVC). */
|
|
58
|
+
const hashAlgorithm = [1, 999, 1];
|
|
59
|
+
|
|
60
|
+
/** Signature algorithm: owner signing (OSG), RSA, ISO 9796-1. */
|
|
61
|
+
const signatureAlgorithm = [6, 10, 16];
|
|
62
|
+
|
|
63
|
+
/** Security function of the encryption head: plain text, since PIN/TAN does not encrypt. */
|
|
64
|
+
const plainText = 998;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Encryption algorithm: owner symmetric (OSY), CBC, 2-key triple DES, a key of zeros, symmetric key (KYE),
|
|
68
|
+
* initialisation value in clear (IVC).
|
|
69
|
+
*/
|
|
70
|
+
const encryptionAlgorithm = [2, 2, 13, new Binary(new Uint8Array(8)), 5, 1];
|
|
71
|
+
|
|
72
|
+
/** Compression function: none. */
|
|
73
|
+
const noCompression = 0;
|
|
74
|
+
|
|
75
|
+
/** Key name: bank, user, key type `S` (signature) or `V` (encryption), key number and version 0. */
|
|
76
|
+
function keyName(c: PinTanCredentials, keyType: string): SegmentValue {
|
|
77
|
+
return [c.countryCode, c.bankCode, c.userId, keyType, 0, 0];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A random integer from 0 below `bound`, from a cryptographically secure source. */
|
|
81
|
+
function secureRandom(bound: number): number {
|
|
82
|
+
const values = new Uint32Array(1);
|
|
83
|
+
crypto.getRandomValues(values);
|
|
84
|
+
return values[0]! % bound;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A random 14-digit reference that ties the signature head (HNSHK) to the signature end (HNSHA). */
|
|
88
|
+
function securityControlReference(): string {
|
|
89
|
+
return `${secureRandom(900000000) + 100000000}${secureRandom(100000).toString().padStart(5, '0')}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A message to send, and the same message with PIN and TAN masked for logging. */
|
|
93
|
+
export class OutgoingMessage {
|
|
94
|
+
constructor(
|
|
95
|
+
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[],
|
|
98
|
+
) {}
|
|
99
|
+
|
|
100
|
+
/** The message with PIN and TAN masked; decoded only when a logger asks for it. */
|
|
101
|
+
get masked(): string {
|
|
102
|
+
return this.maskedParts.map(decodeBankText).join('');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The segments between header and trailer, as sent and with PIN and TAN masked, and the number for the trailer. */
|
|
107
|
+
interface Body {
|
|
108
|
+
readonly bytes: Uint8Array;
|
|
109
|
+
readonly masked: readonly Uint8Array[];
|
|
110
|
+
readonly nextNumber: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Builds a message: header (HNHBK), the `segments`, trailer (HNHBS).
|
|
115
|
+
*
|
|
116
|
+
* With `security` the segments are signed with PIN and optional `tan` (HNSHK … HNSHA) and wrapped in the encryption
|
|
117
|
+
* envelope (HNVSK, HNVSD) that PIN/TAN uses without actual encryption.
|
|
118
|
+
*
|
|
119
|
+
* The masked message states the sizes of its masked content, so that the log does not tell how long PIN and TAN are.
|
|
120
|
+
*/
|
|
121
|
+
export function buildMessage(parts: {
|
|
122
|
+
dialogId: string;
|
|
123
|
+
messageNumber: number;
|
|
124
|
+
segments: readonly SegmentSpec[];
|
|
125
|
+
security?: PinTanCredentials | undefined;
|
|
126
|
+
tan?: string | undefined;
|
|
127
|
+
now?: Date;
|
|
128
|
+
}): OutgoingMessage {
|
|
129
|
+
const body =
|
|
130
|
+
parts.security === undefined
|
|
131
|
+
? plainBody(parts.segments)
|
|
132
|
+
: signedBody(parts.segments, parts.security, parts.tan, parts.now ?? new Date());
|
|
133
|
+
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
|
+
const header = (size: number): Uint8Array =>
|
|
136
|
+
new SegmentSpec('HNHBK', 3, [
|
|
137
|
+
size.toString().padStart(12, '0'),
|
|
138
|
+
300,
|
|
139
|
+
new Verbatim(parts.dialogId),
|
|
140
|
+
parts.messageNumber,
|
|
141
|
+
]).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);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The segments of a message without security, as the anonymous dialog sends them. */
|
|
156
|
+
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
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The segments signed with PIN and optional `tan`, inside the encryption envelope. */
|
|
166
|
+
function signedBody(
|
|
167
|
+
segments: readonly SegmentSpec[],
|
|
168
|
+
security: PinTanCredentials,
|
|
169
|
+
tan: string | undefined,
|
|
170
|
+
time: Date,
|
|
171
|
+
): Body {
|
|
172
|
+
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(
|
|
182
|
+
new SegmentSpec('HNSHK', 4, [
|
|
183
|
+
profile,
|
|
184
|
+
security.securityFunction,
|
|
185
|
+
controlReference,
|
|
186
|
+
signedArea,
|
|
187
|
+
issuer,
|
|
188
|
+
sender(security.systemId),
|
|
189
|
+
securityReference,
|
|
190
|
+
timestamp(time),
|
|
191
|
+
hashAlgorithm,
|
|
192
|
+
signatureAlgorithm,
|
|
193
|
+
keyName(security, 'S'),
|
|
194
|
+
]).build(2),
|
|
195
|
+
);
|
|
196
|
+
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();
|
|
206
|
+
const encryptionHead = new SegmentSpec('HNVSK', 3, [
|
|
207
|
+
profile,
|
|
208
|
+
plainText,
|
|
209
|
+
issuer,
|
|
210
|
+
sender(security.systemId),
|
|
211
|
+
timestamp(time),
|
|
212
|
+
encryptionAlgorithm,
|
|
213
|
+
keyName(security, 'V'),
|
|
214
|
+
noCompression,
|
|
215
|
+
]).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 };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** A received message, with the segments of the encryption envelope unpacked. */
|
|
229
|
+
export class IncomingMessage3 {
|
|
230
|
+
/**
|
|
231
|
+
* Return codes for the whole message (HIRMG), then for single request segments (HIRMS).
|
|
232
|
+
*
|
|
233
|
+
* The segment a HIRMS refers to is not kept: each message carries one order, and the answer that completes an
|
|
234
|
+
* approval reports on the order of an earlier message (PIN/TAN B.4.2.1).
|
|
235
|
+
*/
|
|
236
|
+
readonly feedback: readonly FinTsFeedback[];
|
|
237
|
+
|
|
238
|
+
/** Whether the bank rejected the message as a whole (an error in HIRMG), rather than single orders in it. */
|
|
239
|
+
readonly messageRejected: boolean;
|
|
240
|
+
|
|
241
|
+
private constructor(
|
|
242
|
+
readonly segments: readonly Segment[],
|
|
243
|
+
/** The memory the message holds, in bytes: its length, and what its parts take beyond that. */
|
|
244
|
+
readonly size: number,
|
|
245
|
+
) {
|
|
246
|
+
this.feedback = Object.freeze(
|
|
247
|
+
['HIRMG', 'HIRMS'].flatMap((type) => segments.filter((s) => s.type === type).flatMap(feedbackOf)),
|
|
248
|
+
);
|
|
249
|
+
this.messageRejected = segments
|
|
250
|
+
.filter((s) => s.type === 'HIRMG')
|
|
251
|
+
.flatMap(feedbackOf)
|
|
252
|
+
.some((f) => f.isError);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Parses a message; throws `FormatError` if `data` is not one complete FinTS message, such as the HTML page of a
|
|
257
|
+
* 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.
|
|
259
|
+
*
|
|
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.
|
|
262
|
+
*/
|
|
263
|
+
static parse(data: Uint8Array): IncomingMessage3 {
|
|
264
|
+
const budget = { elements: maxDataElements };
|
|
265
|
+
const outer = parseSegments(data, budget);
|
|
266
|
+
if (outer[0]?.type !== 'HNHBK') throw new FormatError('Not a FinTS message: no message header');
|
|
267
|
+
if (outer.at(-1)?.type !== 'HNHBS') throw new FormatError('Incomplete FinTS message: no message trailer');
|
|
268
|
+
if (outer.filter((s) => s.type === 'HNHBK' || s.type === 'HNHBS').length > 2) {
|
|
269
|
+
throw new FormatError('More than one FinTS message');
|
|
270
|
+
}
|
|
271
|
+
const segments: Segment[] = [];
|
|
272
|
+
for (const s of outer) {
|
|
273
|
+
if (s.type !== 'HNVSD') {
|
|
274
|
+
segments.push(s);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
const payload = s.binary(1);
|
|
278
|
+
if (payload === undefined) throw new FormatError('Encrypted data (HNVSD) without binary data');
|
|
279
|
+
for (const inner of parseSegments(payload, budget)) segments.push(inner);
|
|
280
|
+
}
|
|
281
|
+
return new IncomingMessage3(segments, data.length + (maxDataElements - budget.elements) * dataElementMemory);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
static combine(first: IncomingMessage3, second: IncomingMessage3): IncomingMessage3 {
|
|
285
|
+
return new IncomingMessage3([...first.segments, ...second.segments], first.size + second.size);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Free-text messages the bank addresses to the user. */
|
|
289
|
+
get bankMessages(): BankMessage[] {
|
|
290
|
+
return this.findAll('HIKIM').map((m) => new BankMessage(m.text(1) ?? '', m.text(2) ?? ''));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** The dialog ID the bank assigned, as `Segment.verbatim` reads it, to send back byte for byte. */
|
|
294
|
+
get dialogId(): string | undefined {
|
|
295
|
+
return this.find('HNHBK')?.verbatim(3);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Whether the message answers message `number` of the dialog `dialogId`: header and trailer carry that number, and the
|
|
300
|
+
* header names that message where it names the message it refers to (Bezugsnachricht). The dialog ID counts only once
|
|
301
|
+
* the bank has assigned one; before, `dialogId` is `undefined`, since every first message goes out under the same.
|
|
302
|
+
*/
|
|
303
|
+
answers(number: number, dialogId: string | undefined): boolean {
|
|
304
|
+
const header = this.find('HNHBK');
|
|
305
|
+
// The trailer comes last, after the segments of the encryption envelope, which might hold one of their own.
|
|
306
|
+
const trailer = this.segments.at(-1);
|
|
307
|
+
if (header === undefined || trailer?.type !== 'HNHBS') return false;
|
|
308
|
+
const inDialog = (id: string | undefined): boolean => dialogId === undefined || id === dialogId;
|
|
309
|
+
const namesReference = header.group(5).some((e) => !e.isEmpty);
|
|
310
|
+
return (
|
|
311
|
+
header.integer(4) === number &&
|
|
312
|
+
trailer.integer(1) === number &&
|
|
313
|
+
inDialog(header.verbatim(3)) &&
|
|
314
|
+
(!namesReference || (header.integer(5, 1) === number && inDialog(header.verbatim(5, 0))))
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
find(type: string): Segment | undefined {
|
|
319
|
+
return this.segments.find((s) => s.type === type);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
findAll(type: string): Segment[] {
|
|
323
|
+
return this.segments.filter((s) => s.type === type);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The return codes of `s`. Their parameters are values the client or the app sends back, such as the position to
|
|
329
|
+
* continue at or new login data, so they are read as `Segment.verbatim` reads them.
|
|
330
|
+
*/
|
|
331
|
+
function feedbackOf(s: Segment): FinTsFeedback[] {
|
|
332
|
+
const feedback: FinTsFeedback[] = [];
|
|
333
|
+
for (let i = 1; i < s.length; i++) {
|
|
334
|
+
const code = s.text(i);
|
|
335
|
+
if (code === undefined) continue;
|
|
336
|
+
const parameters: string[] = [];
|
|
337
|
+
// Empty parameters stay, as empty text, so that each keeps its position.
|
|
338
|
+
for (let j = 3; j < s.group(i).length; j++) parameters.push(s.verbatim(i, j) ?? '');
|
|
339
|
+
feedback.push(new FinTsFeedback({ code, reference: s.text(i, 1), text: s.text(i, 2) ?? '', parameters }));
|
|
340
|
+
}
|
|
341
|
+
return feedback;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Unpacks the transport encoding: PIN/TAN servers answer in base64, a few with the raw message.
|
|
346
|
+
*
|
|
347
|
+
* Returns anything else unchanged, for `IncomingMessage3.parse` to reject.
|
|
348
|
+
*/
|
|
349
|
+
export function decodeTransport(body: Uint8Array): Uint8Array {
|
|
350
|
+
if (startsWithMessageHeader(body)) return body;
|
|
351
|
+
try {
|
|
352
|
+
// Base64 is ASCII, which UTF-8 decodes natively; anything else is no base64 either way.
|
|
353
|
+
return decodeBase64Lines(utf8Decode(body, true));
|
|
354
|
+
} catch (error) {
|
|
355
|
+
if (error instanceof FormatError) return body;
|
|
356
|
+
throw error;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const messageHeader = latin1Encode('HNHBK');
|
|
361
|
+
const whitespaceBytes = new Set([0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x20]);
|
|
362
|
+
|
|
363
|
+
/** Whether `body` is a raw message: its header segment comes first, after any white space. */
|
|
364
|
+
function startsWithMessageHeader(body: Uint8Array): boolean {
|
|
365
|
+
let start = 0;
|
|
366
|
+
while (start < body.length && whitespaceBytes.has(body[start]!)) start++;
|
|
367
|
+
if (body.length - start < messageHeader.length) return false;
|
|
368
|
+
return messageHeader.every((byte, i) => body[start + i] === byte);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function encodeTransport(message: Uint8Array): Uint8Array {
|
|
372
|
+
return latin1Encode(base64Encode(message));
|
|
373
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Internal to the package, and named after the FinTS specification. Documentation is where the meaning is not evident.
|
|
2
|
+
|
|
3
|
+
import type { FinTsConfig } from '../core/config.js';
|
|
4
|
+
import { FormatError } from '../util/errors.js';
|
|
5
|
+
import { readOptionalBoolean, readOptionalObject, readOptionalString, type JsonObject } from '../util/json.js';
|
|
6
|
+
import type { IncomingMessage3 } from './message.js';
|
|
7
|
+
import { Bpd3, decodeSegments, Upd3 } from './parameters.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The parameter data and IDs the bank sends the user `config` names, which the messages and business transactions
|
|
11
|
+
* take, and which the saved state keeps: the bank and user parameter data, the customer system ID, and the names of
|
|
12
|
+
* the TAN media as the bank sent them.
|
|
13
|
+
*/
|
|
14
|
+
export class ParameterStore {
|
|
15
|
+
private systemIdValue: string | undefined;
|
|
16
|
+
private bpd: Bpd3 | undefined;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Whether the bank parameter data came from the anonymous dialog. They serve until those of a dialog signed with the
|
|
20
|
+
* PIN come: the bank may send a user other ones (PIN/TAN B.4.3.1), and the anonymous dialog may have had to do
|
|
21
|
+
* without the announcement of strong customer authentication. So signed dialogs report version 0 for them, which has
|
|
22
|
+
* the bank send its parameter data anew.
|
|
23
|
+
*/
|
|
24
|
+
private bpdAnonymous = false;
|
|
25
|
+
private upd: Upd3 | undefined;
|
|
26
|
+
|
|
27
|
+
/**
|
|
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.
|
|
30
|
+
*/
|
|
31
|
+
private tanMediumNames = new Map<string, string>();
|
|
32
|
+
|
|
33
|
+
constructor(readonly config: FinTsConfig) {}
|
|
34
|
+
|
|
35
|
+
/** The bank parameter data received so far, if any. */
|
|
36
|
+
get bankParameters(): Bpd3 | undefined {
|
|
37
|
+
return this.bpd;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The user parameter data received so far, if any. */
|
|
41
|
+
get userParameters(): Upd3 | undefined {
|
|
42
|
+
return this.upd;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The customer system ID the bank assigned in the synchronization, as `Segment.verbatim` reads it; `undefined`
|
|
47
|
+
* before. No dialog signed with the PIN starts without it.
|
|
48
|
+
*/
|
|
49
|
+
get systemId(): string | undefined {
|
|
50
|
+
return this.systemIdValue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The version of the bank parameter data a dialog reports: 0 where there are none, or only the anonymous dialog's. */
|
|
54
|
+
get reportedBpdVersion(): number {
|
|
55
|
+
return this.bpd === undefined || this.bpdAnonymous ? 0 : this.bpd.version;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The version of the user parameter data a signed dialog reports: 0 where there are none, or where they name another
|
|
60
|
+
* user ID than the one logging in, so that the bank sends the user's anew. They may be another user's, as where a
|
|
61
|
+
* relay mixed up the answers to the synchronizations of two users and the bank takes the other user's customer system
|
|
62
|
+
* ID. A bank that names the user by another ID than the login's, such as for an alias, only sends its user parameter
|
|
63
|
+
* data at every login.
|
|
64
|
+
*/
|
|
65
|
+
get reportedUpdVersion(): number {
|
|
66
|
+
const upd = this.upd;
|
|
67
|
+
return upd !== undefined && upd.userId === this.config.userId ? upd.version : 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Drops the customer system ID the bank no longer accepts, so that a synchronization is required again, and with it
|
|
72
|
+
* the user parameter data, which the next login delivers anew: what came along with an ID the bank does not know may
|
|
73
|
+
* not be the user's either (`reportedUpdVersion`). Returns whether there was an ID.
|
|
74
|
+
*/
|
|
75
|
+
forgetSystemId(): boolean {
|
|
76
|
+
const had = this.systemIdValue !== undefined;
|
|
77
|
+
this.systemIdValue = undefined;
|
|
78
|
+
this.upd = undefined;
|
|
79
|
+
return had;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Keeps the parameter data and IDs in `response`, an answer to the opening of a signed dialog: the login, which may be
|
|
84
|
+
* a synchronization, or its approval. The bank sends them there (Formals C.3.1); taken from the answer to an order,
|
|
85
|
+
* the parameter data a bank repeats there in part would replace the complete ones.
|
|
86
|
+
*/
|
|
87
|
+
keep(response: IncomingMessage3): void {
|
|
88
|
+
this.keepBankParameters(response, false);
|
|
89
|
+
this.upd = Upd3.fromSegments(response.segments, this.config.bankCode, this.config.countryCode) ?? this.upd;
|
|
90
|
+
this.systemIdValue = response.find('HISYN')?.verbatim(1) ?? this.systemIdValue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Keeps the bank parameter data in `response`, the answer of an anonymous dialog, where there are none from a dialog
|
|
95
|
+
* signed with the PIN: until those come, the dialogs signed with the PIN have the bank send its parameter data anew,
|
|
96
|
+
* since it may send a user other ones (PIN/TAN B.4.3.1). Keeps nothing that belongs to a user.
|
|
97
|
+
*/
|
|
98
|
+
keepAnonymous(response: IncomingMessage3): void {
|
|
99
|
+
if (this.bpd === undefined || this.bpdAnonymous) this.keepBankParameters(response, true);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private keepBankParameters(response: IncomingMessage3, anonymous: boolean): void {
|
|
103
|
+
const bpd = Bpd3.fromSegments(response.segments, this.config.bankCode);
|
|
104
|
+
if (bpd === undefined) return;
|
|
105
|
+
this.bpd = bpd;
|
|
106
|
+
this.bpdAnonymous = anonymous;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Keeps `names`, the names of the bank's latest list of TAN media as `parseTanMedia` notes them. */
|
|
110
|
+
keepTanMediumNames(names: Map<string, string>): void {
|
|
111
|
+
this.tanMediumNames = names;
|
|
112
|
+
}
|
|
113
|
+
|
|
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;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The parameter data and IDs as JSON data, for the client's saved state. */
|
|
120
|
+
saveState(): JsonObject {
|
|
121
|
+
return {
|
|
122
|
+
systemId: this.systemIdValue ?? null,
|
|
123
|
+
bpd: this.bpd?.encoded ?? null,
|
|
124
|
+
bpdAnonymous: this.bpdAnonymous,
|
|
125
|
+
upd: this.upd?.encoded ?? null,
|
|
126
|
+
tanMediumNames: Object.fromEntries(this.tanMediumNames),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Restores what `saveState` saved; throws if `saved` is damaged, leaving the store unchanged. */
|
|
131
|
+
restoreState(saved: JsonObject): void {
|
|
132
|
+
const bpd = readOptionalString(saved, 'bpd');
|
|
133
|
+
const upd = readOptionalString(saved, 'upd');
|
|
134
|
+
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.
|
|
136
|
+
const restoredBpdAnonymous = readOptionalBoolean(saved, 'bpdAnonymous') ?? true;
|
|
137
|
+
const restoredBpd = bpd === undefined ? undefined : Bpd3.fromSegments(decodeSegments(bpd), this.config.bankCode);
|
|
138
|
+
const restoredUpd =
|
|
139
|
+
upd === undefined
|
|
140
|
+
? undefined
|
|
141
|
+
: Upd3.fromSegments(decodeSegments(upd), this.config.bankCode, this.config.countryCode);
|
|
142
|
+
const restoredNames = readTanMediumNames(saved);
|
|
143
|
+
this.systemIdValue = restoredSystemId;
|
|
144
|
+
this.bpd = restoredBpd;
|
|
145
|
+
this.bpdAnonymous = restoredBpdAnonymous;
|
|
146
|
+
this.upd = restoredUpd;
|
|
147
|
+
this.tanMediumNames = restoredNames;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
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]) => {
|
|
156
|
+
if (typeof asSent !== 'string') throw new FormatError('Saved state: a TAN medium name is not a string');
|
|
157
|
+
return [name, asSent];
|
|
158
|
+
}),
|
|
159
|
+
);
|
|
160
|
+
}
|