forked from nemiah/phpFinTS
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathMessage.php
More file actions
385 lines (351 loc) · 16.3 KB
/
Message.php
File metadata and controls
385 lines (351 loc) · 16.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
<?php
// NOTE: In FinTsTestCase, this namespace name is hard-coded in order to be able to mock the rand() function below.
namespace Fhp\Protocol;
use Fhp\Model\NoPsd2TanMode;
use Fhp\Model\TanMode;
use Fhp\Options\Credentials;
use Fhp\Options\FinTsOptions;
use Fhp\Segment\BaseSegment;
use Fhp\Segment\HIRMS\Rueckmeldung;
use Fhp\Segment\HIRMS\RueckmeldungContainer;
use Fhp\Segment\HNHBK\HNHBKv3;
use Fhp\Segment\HNHBS\HNHBSv1;
use Fhp\Segment\HNSHA\BenutzerdefinierteSignaturV1;
use Fhp\Segment\HNSHA\HNSHAv2;
use Fhp\Segment\HNSHK\HNSHKv4;
use Fhp\Segment\HNVSD\HNVSDv1;
use Fhp\Segment\HNVSK\HNVSKv3;
use Fhp\Syntax\Parser;
use Fhp\Syntax\Serializer;
/**
* NOTE: There is also the (newer) Fhp\Message\Message class.
*
* This class builds a message that has the structure of an encrypted message as defined in the original HBCI
* specification (first link below). However, it implements only the structure and no actual encryption or cryptographic
* signature, because the PIN/TAN specification says not to use the HBCI cryptosystem -- instead there is just
* encryption on the transport level (TLS), which this library implements through Curl provided that the user connects
* to an HTTPS address.
*
* @link https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Security_Sicherheitsverfahren_HBCI_Rel_20181129_final_version.pdf
* Section B.5
*
* @link https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Security_Sicherheitsverfahren_PINTAN_2018-02-23_final_version.pdf
* Section A
*
* @link https://www.hbci-zka.de/dokumente/spezifikation_deutsch/fintsv3/FinTS_3.0_Formals_2017-10-06_final_version.pdf
* Section B.8
*/
class Message
{
/**
* The segments in the original message structure, i.e. before wrapping/"encryption" or after
* unwrapping/"decryption". This excludes all the headers/footers.
* @var BaseSegment[]
*/
public $plainSegments = [];
/**
* The wrapper segments that form the "encrypted" message structure, which includes the plain segments in HNVSD.
* - No. 1: HNHBK Message header
* - No. 998: HNVSK Encryption header
* - No. 999: HNVSD "Encrypted" contents, actually just wrapped plaintext.
* * No. 2 HNSHK Signature head (starts at 2 because there would implicitly be a HNHBK at 1)
* * No. 3 through N+2: All the $plainSegments (with N := count($plainSegments))
* * No. N+3: HNSHA Signature footer
* - No. N+4: HNHBS Message footer
* @var BaseSegment[]
*/
public $wrapperSegments = [];
/**
* The same HNHBK segment that is also stored inside $wrappedSegments above.
* @var HNHBKv3
*/
public $header;
/**
* The same HNHBS segment that is also stored inside $wrappedSegments above.
* @var HNHBSv1
*/
public $footer;
/** @var HNSHKv4|null */
public $signatureHeader;
/** @var HNSHAv2|null */
public $signatureFooter;
/**
* @return \Generator|BaseSegment[] All plain and wrapper segments in this message.
*/
public function getAllSegments()
{
yield from $this->plainSegments;
yield from $this->wrapperSegments;
}
/**
* @throws \InvalidArgumentException If any segment in this message is invalid.
*/
public function validate()
{
foreach ($this->getAllSegments() as $segment) {
try {
$segment->validate();
} catch (\InvalidArgumentException $e) {
throw new \InvalidArgumentException("Invalid segment {$segment->segmentkopf->segmentkennung}", 0, $e);
}
}
}
// TODO Add unit test coverage for the functions below.
/**
* @param string $segmentType The PHP type (class name or interface) of the segment(s).
* @return BaseSegment[] All segments of this type (possibly an empty array).
*/
public function findSegments(string $segmentType): array
{
return array_values(array_filter($this->plainSegments, function ($segment) use ($segmentType) {
/* @var BaseSegment $segment */
return $segment instanceof $segmentType;
}));
}
/**
* @param string $segmentType The PHP type (class name or interface) of the segment.
* @return BaseSegment|null The segment, or null if it was found.
*/
public function findSegment(string $segmentType): ?BaseSegment
{
$matchedSegments = $this->findSegments($segmentType);
if (count($matchedSegments) > 1) {
throw new UnexpectedResponseException("Multiple segments matched $segmentType");
}
return count($matchedSegments) === 0 ? null : $matchedSegments[0];
}
/**
* @param string $segmentType The PHP type (class name or interface) of the segment.
* @return bool Whether any such segment exists.
*/
public function hasSegment(string $segmentType): bool
{
return $this->findSegment($segmentType) !== null;
}
/**
* @param string $segmentType The PHP type (class name or interface) of the segment.
* @return BaseSegment The segment, never null.
* @throws UnexpectedResponseException If the segment was not found.
*/
public function requireSegment(string $segmentType): BaseSegment
{
$matchedSegment = $this->findSegment($segmentType);
if ($matchedSegment === null) {
throw new UnexpectedResponseException("Segment not found: $segmentType");
}
return $matchedSegment;
}
/**
* @param int $segmentNumber The segment number to search for.
* @return BaseSegment|null The segment with that number, or null if there is none.
*/
public function findSegmentByNumber(int $segmentNumber): ?BaseSegment
{
foreach ($this->getAllSegments() as $segment) {
if ($segment->getSegmentNumber() === $segmentNumber) {
return $segment;
}
}
return null;
}
/**
* @param int[] $referenceNumbers The numbers of the reference segments.
* @return Message A new message that just contains the plain segment from $this message which refer to one
* of the given $referenceSegments.
*/
public function filterByReferenceSegments(array $referenceNumbers): Message
{
$result = new Message();
if (count($referenceNumbers) === 0) {
return $result;
}
$result->plainSegments = array_filter($this->plainSegments, function ($segment) use ($referenceNumbers) {
/** @var BaseSegment $segment */
$referenceNumber = $segment->segmentkopf->bezugselement;
return $referenceNumber !== null && in_array($referenceNumber, $referenceNumbers);
});
$result->header = $this->header;
$result->footer = $this->footer;
$result->signatureHeader = $this->signatureHeader;
$result->signatureFooter = $this->signatureFooter;
return $result;
}
/**
* @param int $code The response code to search for.
* @param ?int $requestSegmentNumber If set, only consider Rueckmeldungen that pertain to this request segment.
* @return Rueckmeldung|null The corresponding Rueckmeldung instance, or null if not found.
*/
public function findRueckmeldung(int $code, ?int $requestSegmentNumber = null): ?Rueckmeldung
{
foreach ($this->plainSegments as $segment) {
if (
$segment instanceof RueckmeldungContainer && (
$requestSegmentNumber === null || $segment->segmentkopf->bezugselement === $requestSegmentNumber
)
) {
$rueckmeldung = $segment->findRueckmeldung($code);
if ($rueckmeldung !== null) {
return $rueckmeldung;
}
}
}
return null;
}
/** @return Rueckmeldung[] */
public function findRueckmeldungen(int $code): array
{
$rueckmeldungen = [];
foreach ($this->plainSegments as $segment) {
if ($segment instanceof RueckmeldungContainer) {
$rueckmeldungen = array_merge($rueckmeldungen, $segment->findRueckmeldungen($code));
}
}
return $rueckmeldungen;
}
/**
* @param int $requestSegmentNumber Only consider Rueckmeldungen that pertain to this request segment.
* @return int[] The codes of all the Rueckmeldung instances matching the request segment.
*/
public function findRueckmeldungscodesForReferenceSegment(int $requestSegmentNumber): array
{
$codes = [];
foreach ($this->plainSegments as $segment) {
if ($segment instanceof RueckmeldungContainer && $segment->segmentkopf->bezugselement === $requestSegmentNumber) {
foreach ($segment->getAllRueckmeldungen() as $rueckmeldung) {
$codes[] = $rueckmeldung->rueckmeldungscode;
}
}
}
return $codes;
}
/**
* @return string The HBCI/FinTS wire format for this message, ISO-8859-1 encoded.
*/
public function serialize(): string
{
return Serializer::serializeSegments($this->wrapperSegments);
}
/**
* Wraps the given segments in an "encryption" envelope (see class documentation). Inverse of {@link parse()}.
* @param BaseSegment[]|MessageBuilder $plainSegments The plain segments to be wrapped. Segment numbers do not need
* to be set yet (or they will be overwritten).
* @param FinTsOptions $options See {@link FinTsOptions}.
* @param string $kundensystemId See {@link $kundensystemId}.
* @param Credentials $credentials The credentials used to authenticate the message.
* @param TanMode|null $tanMode Optionally specifies which two-step TAN mode to use, defaults to 999 (single step).
* @param string|null The TAN to be sent to the server (in HNSHA). If this is present, $tanMode must be present.
* @return Message The built message, ready to be sent to the server through {@link FinTs::sendMessage()}.
*/
public static function createWrappedMessage($plainSegments, FinTsOptions $options, string $kundensystemId, Credentials $credentials, ?TanMode $tanMode, $tan): Message
{
$message = new Message();
$message->plainSegments = $plainSegments instanceof MessageBuilder ? $plainSegments->segments : $plainSegments;
$tanMode = $tanMode instanceof NoPsd2TanMode ? null : $tanMode;
$randomReference = strval(rand(1000000, 9999999)); // Call unqualified rand() for unit test mocking to work.
$signature = BenutzerdefinierteSignaturV1::create($credentials->getPin(), $tan);
$numPlainSegments = count($message->plainSegments); // This is N, see $encryptedSegments.
$message->wrapperSegments = [ // See $encryptedSegments documentation for the structure.
$message->header = HNHBKv3::createEmpty()->setSegmentNumber(1),
HNVSKv3::create($options, $credentials, $kundensystemId, $tanMode), // Segment number 998
HNVSDv1::create(array_merge( // Segment number 999
[$message->signatureHeader = HNSHKv4::create(
$randomReference, $options, $credentials, $tanMode, $kundensystemId
)->setSegmentNumber(2)],
static::setSegmentNumbers($message->plainSegments, 3),
[$message->signatureFooter = HNSHAv2::create($randomReference, $signature)
->setSegmentNumber($numPlainSegments + 3), ]
)),
$message->footer = HNHBSv1::createEmpty()->setSegmentNumber($numPlainSegments + 4),
];
return $message;
}
/**
* Builds a plain message by adding header and footer to the given segments, but no "encryption" envelope.
* Inverse of {@link parse()}.
* @param BaseSegment[]|MessageBuilder $segments
* @return Message The built message, ready to be sent to the server through {@link FinTs::sendMessage()}.
*/
public static function createPlainMessage($segments): Message
{
$message = new Message();
$message->plainSegments = $segments instanceof MessageBuilder ? $segments->segments : $segments;
$message->wrapperSegments = array_merge(
[$message->header = HNHBKv3::createEmpty()->setSegmentNumber(1)],
static::setSegmentNumbers($message->plainSegments, 2),
[$message->footer = HNHBSv1::createEmpty()->setSegmentNumber(2 + count($message->plainSegments))]
);
return $message;
}
/**
* Parses the given wire format and unwraps the "encryption" envelope (see class documentation) if it exists
* (in which case this function acts as the inverse of {@link createWrappedMessage()}), or leaves as is otherwise
* (and acts as inverse of {@link createPlainMessage()}).
*
* @param string $rawMessage The received message in HBCI/FinTS wire format. This should be ISO-8859-1-encoded.
* @return Message The parsed message.
* @throws \InvalidArgumentException When the parsing fails.
*/
public static function parse(string $rawMessage): Message
{
$result = new Message();
$segments = Parser::parseSegments($rawMessage);
// Message header and footer must always be there, or something went badly wrong.
$result->header = $segments[0];
$result->footer = $segments[count($segments) - 1];
if (!$result->header instanceof HNHBKv3) {
$actual = $result->header->getName();
throw new \InvalidArgumentException("Expected first segment to be HNHBK, but got $actual: $rawMessage");
}
if (!$result->footer instanceof HNHBSv1) {
$actual = $result->footer->getName();
throw new \InvalidArgumentException("Expected last segment to be HNHBS, but got $actual: $rawMessage");
}
// Check if there's an encryption header and "encrypted" data.
// Section B.8 specifies that there are exactly 4 segments: HNHBK, HNVSK, HNVSD, HNHBS.
if (count($segments) === 4 && $segments[1] instanceof HNVSKv3) {
if (!$segments[2] instanceof HNVSDv1) {
throw new \InvalidArgumentException("Expected third segment to be HNVSD: $rawMessage");
}
$result->wrapperSegments = $segments;
$result->plainSegments = Parser::parseSegments($segments[2]->datenVerschluesselt->getData());
// Signature header and footer must always be there when the "encrypted" structure was used.
// Postbank is not following the Spec and does not send the Header and Footer
$signatureFooterAsExpected = end($result->plainSegments) instanceof HNSHAv2;
$signatureHeaderAsExpected = reset($result->plainSegments) instanceof HNSHKv4;
if ($signatureHeaderAsExpected xor $signatureFooterAsExpected) {
throw new \InvalidArgumentException("Expected first segment to be HNSHK and last segement to be HNSHA or both to be absent: $rawMessage");
}
if ($signatureHeaderAsExpected) {
$result->signatureHeader = array_shift($result->plainSegments);
}
if ($signatureFooterAsExpected) {
$result->signatureFooter = array_pop($result->plainSegments);
}
} else {
// Ensure that there's no encryption header anywhere, and we haven't just misunderstood the format.
foreach ($segments as $segment) {
if ($segment->getName() === 'HNVSK' || $segment->getName() === 'HNVSD') {
throw new \InvalidArgumentException("Unexpected encrypted format: $rawMessage");
}
}
$result->plainSegments = $segments; // The message wasn't "encrypted".
}
return $result;
}
/**
* @param BaseSegment[] $segments The segments to be numbered. Will be modified.
* @param int $segmentNumber The number for the *first* segment, subsequent segment get the subsequent integers.
* @return BaseSegment[] The same array, for chaining.
*/
public static function setSegmentNumbers(array $segments, int $segmentNumber): array
{
foreach ($segments as $segment) {
$segment->segmentkopf->segmentnummer = $segmentNumber;
if ($segment->segmentkopf->segmentnummer >= HNVSKv3::SEGMENT_NUMBER) {
throw new \InvalidArgumentException('Too many segments');
}
++$segmentNumber;
}
return $segments;
}
}