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; } }