Skip to content

Latest commit

 

History

History
895 lines (671 loc) · 27.8 KB

File metadata and controls

895 lines (671 loc) · 27.8 KB

Research: AS2 Server Technical Foundations

Feature: AS2 Server Date: 2025-11-14 Status: Complete

This document consolidates research findings for technical decisions required to implement the AS2 Server. Each section addresses specific unknowns identified during planning.

1. RFC 4130 Specification Deep-Dive

Decision

Implement strict RFC 4130 compliance with all mandatory requirements and recommended features.

Key Requirements from RFC 4130

Mandatory HTTP Headers (Section 6.1):

  • AS2-Version: 1.0 or 1.1 or 1.2 (negotiate with partner)
  • AS2-From: <AS2-Name> (partner identifier, case-sensitive)
  • AS2-To: <AS2-Name> (recipient identifier, case-sensitive)
  • Message-ID: <unique-id@domain> (RFC 2822 format, ≤998 chars, should be ≤255 chars)
  • Host: <hostname> (standard HTTP header)
  • Content-Type: <mime-type> (e.g., multipart/signed, application/pkcs7-mime)

Optional Headers for Receipts:

  • Disposition-Notification-To: <email or AS2-Name> (request receipt)
  • Disposition-Notification-Options: signed-receipt-protocol=optional, pkcs7-signature; signed-receipt-micalg=optional, sha256 (specify signing requirements for MDN)
  • Receipt-Delivery-Option: <URL> (URL for asynchronous MDN delivery)

MIME Structure Requirements (Section 3):

  • Signed Message: multipart/signed with two parts:
    1. Content part (original message)
    2. Signature part (application/pkcs7-signature)
  • Encrypted Message: application/pkcs7-mime; smime-type=enveloped-data
  • Signed+Encrypted Message: Encrypt the entire multipart/signed message (sign first, then encrypt)

MDN Structure (Section 7):

  • Content-Type: multipart/report; report-type=disposition-notification
  • Three parts:
    1. Human-readable text (optional)
    2. message/disposition-notification with structured fields
    3. Original message headers or body (optional for debugging)

MDN Disposition Field Format:

Disposition: automatic-action/MDN-sent-automatically; processed

or

Disposition: automatic-action/MDN-sent-automatically; failed/error: authentication-failed

MIC Calculation (Message Integrity Check - Section 7.3):

  • Compute hash over the original message content before encryption
  • Algorithms: SHA-1 (legacy), SHA-256 (recommended), SHA-512
  • Include in MDN: Received-content-MIC: <base64-hash>, <algorithm>
  • Sender must verify MIC in MDN matches originally computed MIC

Message-ID Requirements:

  • Format: <unique-string@domain> per RFC 2822
  • Must be globally unique across all messages
  • Maximum length: 998 characters (SHOULD be ≤255 for compatibility)
  • Used for duplicate detection and MDN correlation

Rationale

RFC 4130 is the authoritative specification for AS2. Strict compliance ensures interoperability with all trading partner systems. Deviations risk message rejection or non-repudiation failures.

Alternatives Considered

  • Relaxed compliance: Could implement subset of features, but trading partners may require specific features (async MDN, certain algorithms). Full compliance ensures maximum compatibility.
  • AS2 version support: Could support only AS2 1.2 (latest), but many legacy systems use AS2 1.0. Must support all versions (1.0, 1.1, 1.2) and negotiate via headers.

Implementation Notes

  • Create MessageHeaders class to encapsulate header validation
  • Create MimeStructureValidator to verify multipart/signed and application/pkcs7-mime structure
  • Create MdnBuilder to construct RFC-compliant MDNs
  • Use OpenSSL for all cryptographic operations (see section 2)

2. OpenSSL PHP Functions for S/MIME Operations

Decision

Use PHP's native OpenSSL extension functions for all S/MIME operations. No third-party cryptographic libraries.

OpenSSL Functions Required

Encryption (application/pkcs7-mime enveloped-data):

openssl_pkcs7_encrypt(
    string $input_filename,
    string $output_filename,
    OpenSSLCertificate|array|string $certificate,
    ?array $headers,
    int $flags = 0,
    int $cipher_algo = OPENSSL_CIPHER_AES_256_CBC
): bool
  • $certificate: Recipient's public certificate
  • $cipher_algo: Use OPENSSL_CIPHER_3DES, OPENSSL_CIPHER_AES_128_CBC, OPENSSL_CIPHER_AES_192_CBC, or OPENSSL_CIPHER_AES_256_CBC
  • Must write to files (no string-based API), use temp files

Decryption (application/pkcs7-mime enveloped-data):

openssl_pkcs7_decrypt(
    string $input_filename,
    string $output_filename,
    OpenSSLCertificate|string $certificate,
    OpenSSLAsymmetricKey|OpenSSLCertificate|array|string $private_key
): bool
  • $certificate: Recipient's certificate (optional, can pass NULL)
  • $private_key: Recipient's private key (can be encrypted with passphrase)

Signing (multipart/signed with application/pkcs7-signature):

openssl_pkcs7_sign(
    string $input_filename,
    string $output_filename,
    OpenSSLCertificate|string $certificate,
    OpenSSLAsymmetricKey|OpenSSLCertificate|array|string $private_key,
    ?array $headers,
    int $flags = PKCS7_DETACHED,
    ?string $untrusted_certificates_filename = null
): bool
  • Use PKCS7_DETACHED flag for detached signatures (multipart/signed)
  • $certificate: Signer's certificate
  • $private_key: Signer's private key

Signature Verification (multipart/signed):

openssl_pkcs7_verify(
    string $input_filename,
    int $flags,
    ?string $signers_certificates_filename = null,
    array $ca_info = [],
    ?string $untrusted_certificates_filename = null,
    ?string $content = null,
    ?string $output_filename = null
): int|bool
  • Returns true for valid signature, false for invalid
  • Use PKCS7_NOVERIFY to skip certificate chain validation (if trusting partner cert directly)
  • Use PKCS7_DETACHED for detached signature verification

Certificate Loading:

openssl_x509_read(OpenSSLCertificate|string $certificate): OpenSSLCertificate|false

Certificate Parsing:

openssl_x509_parse(OpenSSLCertificate|string $certificate, bool $short_names = true): array|false

Returns array with:

  • subject: Distinguished name
  • issuer: Certificate authority
  • validFrom_time_t: Unix timestamp
  • validTo_time_t: Unix timestamp
  • purposes: Key usage array

Private Key Loading:

openssl_pkey_get_private(
    OpenSSLAsymmetricKey|OpenSSLCertificate|array|string $private_key,
    ?string $passphrase = null
): OpenSSLAsymmetricKey|false

Rationale

PHP's OpenSSL extension is battle-tested, widely available, and provides all necessary S/MIME operations. Using native functions avoids third-party dependencies and ensures maximum compatibility across PHP environments.

Alternatives Considered

  • phpseclib: Pure PHP cryptography library. Rejected because it's slower than native OpenSSL and adds unnecessary dependency.
  • External OpenSSL CLI: Could shell out to openssl command-line tool. Rejected due to portability concerns and performance overhead.

Implementation Notes

  • All OpenSSL functions require file paths (no in-memory string operations)
  • Use PHP temp files: tmpfile() returns stream resource, stream_get_meta_data()['uri'] gets path
  • Wrap all OpenSSL calls in try-catch and throw typed exceptions (DecryptionException, SignatureVerificationException)
  • Certificate caching: parse certificates once, cache results in memory
  • Private key security: support encrypted private keys with passphrase (passed via configuration)

Encryption Example:

$input = tmpfile();
fwrite($input, $message_content);
$inputPath = stream_get_meta_data($input)['uri'];

$output = tmpfile();
$outputPath = stream_get_meta_data($output)['uri'];

$cert = openssl_x509_read(file_get_contents('/path/to/partner-cert.pem'));

if (!openssl_pkcs7_encrypt($inputPath, $outputPath, $cert, null, 0, OPENSSL_CIPHER_AES_256_CBC)) {
    throw new EncryptionException('Encryption failed: ' . openssl_error_string());
}

$encrypted = file_get_contents($outputPath);
fclose($input);
fclose($output);

3. MIME multipart/signed and application/pkcs7-mime Structures

Decision

Use custom MIME parser for multipart/signed. Rely on OpenSSL for application/pkcs7-mime (encrypted data).

multipart/signed Structure

RFC 1847 defines multipart/signed:

Content-Type: multipart/signed; protocol="application/pkcs7-signature"; micalg=sha256; boundary="----boundary"

------boundary
Content-Type: application/edi-x12

[Original message content]
------boundary
Content-Type: application/pkcs7-signature

[Base64-encoded PKCS#7 signature]
------boundary--

Parsing Requirements:

  1. Parse boundary from Content-Type header
  2. Split message into parts using boundary
  3. First part: original content (before signing)
  4. Second part: detached signature
  5. Extract micalg (hash algorithm) from Content-Type header

application/pkcs7-mime Structure

RFC 2633 defines application/pkcs7-mime for encrypted data:

Content-Type: application/pkcs7-mime; smime-type=enveloped-data; name=smime.p7m
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename=smime.p7m

[Base64-encoded PKCS#7 encrypted data]

Decryption Process:

  1. Base64-decode the content
  2. Pass to openssl_pkcs7_decrypt()
  3. Decrypted output is the original message (which may be multipart/signed if signed+encrypted)

Signed + Encrypted Message Flow

Sending (sender perspective):

  1. Sign original message → produces multipart/signed
  2. Encrypt entire multipart/signed message → produces application/pkcs7-mime
  3. Send application/pkcs7-mime via HTTP POST

Receiving (recipient perspective):

  1. Receive application/pkcs7-mime via HTTP POST
  2. Decrypt → yields multipart/signed
  3. Verify signature on multipart/signed → yields original message
  4. Compute MIC on original message
  5. Generate MDN with MIC

Rationale

Custom MIME parser gives full control over boundary parsing, Content-Type handling, and multipart structure validation. OpenSSL handles the cryptographic operations, which are complex and security-critical.

Alternatives Considered

  • PHP Mail/IMAP extension: Has MIME parsing functions, but designed for email, not AS2. Missing AS2-specific features.
  • Third-party MIME library (e.g., SwiftMailer MIME): Adds dependency. AS2 MIME is simple enough to parse directly.

Implementation Notes

  • Create MimeParser class with methods:
    • parseMultipartSigned(string $message): array → returns [content, signature, algorithm]
    • extractBoundary(string $contentType): string
    • splitParts(string $message, string $boundary): array
  • Use regex to parse Content-Type header: /boundary="?([^";\s]+)"?/
  • Validate each part has proper Content-Type header
  • Handle Content-Transfer-Encoding: base64 (decode before processing)

Example MIME Parser:

class MimeParser
{
    public function parseMultipartSigned(string $message): array
    {
        // Extract Content-Type header
        $headers = $this->extractHeaders($message);
        $contentType = $headers['content-type'] ?? throw new MimeParseException('Missing Content-Type');

        // Extract boundary
        if (!preg_match('/boundary="?([^";\s]+)"?/', $contentType, $matches)) {
            throw new MimeParseException('Missing boundary in multipart/signed');
        }
        $boundary = $matches[1];

        // Split into parts
        $parts = $this->splitParts($message, $boundary);
        if (count($parts) !== 2) {
            throw new MimeParseException('multipart/signed must have exactly 2 parts, found ' . count($parts));
        }

        // Extract micalg
        if (!preg_match('/micalg="?([^";\s]+)"?/', $contentType, $matches)) {
            throw new MimeParseException('Missing micalg in multipart/signed');
        }

        return [
            'content' => $parts[0],
            'signature' => $parts[1],
            'algorithm' => $matches[1]
        ];
    }
}

4. PSR-7 HTTP Message Patterns for AS2

Decision

Use PSR-7 ServerRequestInterface for incoming messages and ResponseInterface for MDNs. Create AS2-specific wrapper classes for convenience.

PSR-7 Interfaces

Incoming AS2 Message:

use Psr\Http\Message\ServerRequestInterface;

$request = /* ... from HTTP server ... */;

// AS2 headers
$as2Version = $request->getHeaderLine('AS2-Version');
$as2From = $request->getHeaderLine('AS2-From');
$as2To = $request->getHeaderLine('AS2-To');
$messageId = $request->getHeaderLine('Message-ID');

// Body (MIME message)
$body = $request->getBody();
$content = $body->getContents(); // or $body->__toString()

Outgoing MDN:

use Psr\Http\Message\ResponseInterface;
use GuzzleHttp\Psr7\Response;

$mdn = new Response(
    200,
    [
        'AS2-Version' => '1.2',
        'AS2-From' => $serverAs2Name,
        'AS2-To' => $partnerAs2Name,
        'Message-ID' => $mdnMessageId,
        'Content-Type' => 'multipart/report; report-type=disposition-notification; boundary="----MDN"',
    ],
    $mdnBody
);

return $mdn;

AS2 Wrapper Classes

Create convenience wrappers that enforce AS2 requirements:

class As2Request
{
    private ServerRequestInterface $request;

    public function __construct(ServerRequestInterface $request)
    {
        $this->request = $request;
        $this->validate();
    }

    private function validate(): void
    {
        $required = ['AS2-Version', 'AS2-From', 'AS2-To', 'Message-ID', 'Host'];
        foreach ($required as $header) {
            if (!$this->request->hasHeader($header)) {
                throw new MessageParseException("Missing required header: {$header}");
            }
        }

        $messageId = $this->request->getHeaderLine('Message-ID');
        if (strlen($messageId) > 998) {
            throw new MessageParseException("Message-ID exceeds 998 characters: {$messageId}");
        }
    }

    public function getAs2From(): string
    {
        return $this->request->getHeaderLine('AS2-From');
    }

    public function getMessageId(): string
    {
        return $this->request->getHeaderLine('Message-ID');
    }

    public function getBody(): string
    {
        return $this->request->getBody()->getContents();
    }

    public function requestsMdn(): bool
    {
        return $this->request->hasHeader('Disposition-Notification-To');
    }

    public function requestsAsyncMdn(): bool
    {
        return $this->request->hasHeader('Receipt-Delivery-Option');
    }

    public function getAsyncMdnUrl(): ?string
    {
        return $this->request->hasHeader('Receipt-Delivery-Option')
            ? $this->request->getHeaderLine('Receipt-Delivery-Option')
            : null;
    }
}

Rationale

PSR-7 provides standard HTTP message interfaces that work across all PHP HTTP implementations (Guzzle, Symfony HttpFoundation bridge, Laminas Diactoros, etc.). Wrapper classes hide PSR-7 complexity and enforce AS2-specific validation.

Alternatives Considered

  • Direct array manipulation: Could use $_SERVER, $_POST, etc. Rejected because not testable and ties code to global state.
  • Custom HTTP abstraction: Could create own request/response objects. Rejected because PSR-7 is standard and widely supported.

Implementation Notes

  • Use guzzlehttp/psr7 package for PSR-7 implementations (Response, Stream, etc.)
  • Create As2Request wrapper for incoming requests
  • Create As2Response builder for MDNs
  • All layer interfaces accept PSR-7 interfaces, not wrapper classes (wrappers are convenience only)

5. Guzzle Production Configuration Best Practices

Decision

Wrap Guzzle client in HttpClient class with production-ready defaults: timeouts, retries, connection pooling, and error handling.

Guzzle Configuration

use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;

class HttpClient
{
    private Client $client;

    public function __construct(array $config = [])
    {
        $stack = HandlerStack::create();

        // Retry middleware (exponential backoff)
        $stack->push(Middleware::retry(
            function (int $retries, RequestInterface $request, ?ResponseInterface $response = null, ?\Exception $exception = null): bool {
                // Retry on connection errors or 5xx responses
                if ($exception || ($response && $response->getStatusCode() >= 500)) {
                    return $retries < 3; // Max 3 retries
                }
                return false;
            },
            function (int $retries): int {
                // Exponential backoff: 1s, 2s, 4s
                return 1000 * (2 ** $retries);
            }
        ));

        // Logging middleware (PSR-3 logger)
        if (isset($config['logger'])) {
            $stack->push(Middleware::log($config['logger'], new MessageFormatter()));
        }

        $this->client = new Client(array_merge([
            'handler' => $stack,
            'timeout' => 30.0,          // 30 second timeout
            'connect_timeout' => 10.0,  // 10 second connection timeout
            'http_errors' => true,      // Throw exceptions on 4xx/5xx
            'allow_redirects' => false, // AS2 should not redirect
            'verify' => true,           // Verify SSL certificates
        ], $config));
    }

    public function post(string $url, array $options = []): ResponseInterface
    {
        try {
            return $this->client->post($url, $options);
        } catch (ConnectException $e) {
            throw new TransmissionException(
                "Failed to connect to partner endpoint: {$url}. " .
                "Check network connectivity and endpoint URL. " .
                "Original error: {$e->getMessage()}",
                0,
                $e
            );
        } catch (RequestException $e) {
            $response = $e->getResponse();
            $statusCode = $response ? $response->getStatusCode() : 'unknown';
            throw new TransmissionException(
                "Partner rejected message with HTTP {$statusCode}. " .
                "Check partnership configuration and message format. " .
                "URL: {$url}",
                0,
                $e
            );
        }
    }
}

Connection Pooling

Guzzle automatically reuses connections when sending multiple requests through the same client instance:

// Reuse client instance across multiple sends (connection pooling)
$httpClient = new HttpClient();

foreach ($outboundMessages as $message) {
    $httpClient->post($message->getPartnerUrl(), [
        'headers' => $message->getHeaders(),
        'body' => $message->getBody()
    ]);
}

Rationale

Production AS2 systems must handle network failures, partner downtime, and timeout gracefully. Guzzle provides battle-tested HTTP client with retry logic, middleware system, and connection pooling.

Alternatives Considered

  • cURL directly: Could use PHP's curl_* functions. Rejected because Guzzle provides higher-level abstractions, better error handling, and PSR-7 compatibility.
  • Symfony HTTP client: Good alternative, but Guzzle has larger ecosystem and better documentation.

Implementation Notes

  • HttpClient wrapper accepts optional logger (PSR-3) for audit trail
  • Timeout configuration: timeout for full request, connect_timeout for connection only
  • Disable redirects (AS2 endpoints should never redirect)
  • Verify SSL certificates by default (can disable for testing)
  • Retry only on transient errors (5xx, connection failures), not 4xx client errors

6. File-Based Storage Patterns for High-Performance Message-ID Indexing

Decision

Use hierarchical directory structure for messages with separate JSON index file for O(1) Message-ID lookups.

Directory Structure

storage/messages/
├── 2025-11-14/
│   ├── partner-a/
│   │   ├── <message-id-1>.dat    # Message file
│   │   ├── <message-id-2>.dat
│   │   └── <message-id-3>.dat
│   └── partner-b/
│       └── <message-id-4>.dat
└── 2025-11-15/
    └── partner-a/
        └── <message-id-5>.dat

Index Structure

JSON file: storage/index/message-index.json

{
  "<message-id-1>": {
    "file_path": "storage/messages/2025-11-14/partner-a/<message-id-1>.dat",
    "timestamp": 1699920000,
    "partner_id": "partner-a",
    "direction": "inbound",
    "status": "processed"
  },
  "<message-id-2>": {
    "file_path": "storage/messages/2025-11-14/partner-a/<message-id-2>.dat",
    "timestamp": 1699920100,
    "partner_id": "partner-a",
    "direction": "outbound",
    "status": "acknowledged"
  }
}

Index Operations

Load Index (at startup or first access):

class MessageIndex
{
    private array $index = [];
    private string $indexPath;

    public function __construct(string $indexPath)
    {
        $this->indexPath = $indexPath;
        $this->load();
    }

    private function load(): void
    {
        if (file_exists($this->indexPath)) {
            $this->index = json_decode(file_get_contents($this->indexPath), true, 512, JSON_THROW_ON_ERROR);
        }
    }

    public function add(string $messageId, array $metadata): void
    {
        $this->index[$messageId] = $metadata;
        $this->save();
    }

    public function find(string $messageId): ?array
    {
        return $this->index[$messageId] ?? null;
    }

    private function save(): void
    {
        file_put_contents(
            $this->indexPath,
            json_encode($this->index, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
            LOCK_EX
        );
    }
}

Performance: O(1) lookup by Message-ID (hash table lookup in PHP array). With millions of messages, index file may be large (~100MB for 1M messages), but PHP can handle this in memory.

Scalability Considerations

For very large deployments (>1M messages):

  • Shard index by date: One index file per day/week/month
  • Use SQLite: Lightweight file-based database with indexed queries
  • Future: PDO repository: Migrate to MySQL/PostgreSQL with indexed message_id column

Rationale

File-based storage is simple, portable, requires no database setup, and performs well for small-to-medium deployments (<1M messages). Index file provides fast lookups without scanning directories.

Alternatives Considered

  • SQLite: Better for >1M messages, but adds complexity and dependency. File-based sufficient for initial implementation.
  • Directory scanning: Could scan directories for message files. Rejected due to poor performance (O(n) scan vs O(1) index lookup).
  • Filename-based index: Could encode metadata in filename. Rejected because filenames have length/character limits and metadata would be split across filename and file content.

Implementation Notes

  • Use LOCK_EX when writing index file to prevent corruption from concurrent writes
  • Sanitize Message-ID for filesystem: replace /, \, : with _
  • Lazy-load index: don't load until first find() call
  • Atomic file writes: write to temp file, then rename() (atomic on POSIX systems)

7. PHP 8.4 Type System Features

Decision

Leverage PHP 8.4 features: strict types, readonly properties, property hooks, and asymmetric visibility for immutable value objects.

PHP 8.4 Features for AS2 Implementation

Strict Types (mandatory):

declare(strict_types=1);

All files must declare strict types at the top. Enables strict type checking for function arguments and return values.

Readonly Properties (for value objects):

readonly class As2Message
{
    public function __construct(
        public readonly string $messageId,
        public readonly string $as2From,
        public readonly string $as2To,
        public readonly string $content,
        public readonly ?string $signature = null,
    ) {}
}

Value objects are immutable after construction. Readonly enforces immutability at language level.

Property Hooks (PHP 8.4 new feature):

class Partnership
{
    public string $as2Id {
        set {
            if (empty($value)) {
                throw new \InvalidArgumentException('AS2-ID cannot be empty');
            }
            $this->as2Id = $value;
        }
    }
}

Property hooks allow validation logic directly in property declarations.

Asymmetric Visibility (PHP 8.4 new feature):

class Transmission
{
    public private(set) string $status = 'pending';

    public function markAcknowledged(): void
    {
        $this->status = 'acknowledged'; // OK: private write access
    }
}

$transmission = new Transmission();
echo $transmission->status; // OK: public read access
$transmission->status = 'failed'; // ERROR: private write access

Public read, private write. Useful for state management.

Intersection Types (PHP 8.1+):

interface EncryptionStrategyInterface {}
interface LoggableInterface {}

function process(EncryptionStrategyInterface&LoggableInterface $strategy): void
{
    // $strategy must implement both interfaces
}

Union Types (PHP 8.0+):

function parseMessage(string|StreamInterface $input): As2Message
{
    // Accept string or PSR-7 stream
}

Named Arguments (PHP 8.0+):

$message = new As2Message(
    messageId: '<12345@example.com>',
    as2From: 'PARTNER-A',
    as2To: 'SERVER-B',
    content: $payload,
);

Improves readability for objects with many optional parameters.

Rationale

PHP 8.4 type system features improve code safety, reduce bugs, and make code self-documenting. Readonly properties enforce immutability without boilerplate. Property hooks reduce validation code. Asymmetric visibility provides fine-grained encapsulation.

Alternatives Considered

  • PHP 8.3: Could target PHP 8.3 for wider compatibility. Rejected because PHP 8.4 is stable (released Nov 2024) and project requirement specifies PHP 8.4.
  • No strict types: Could use loose type coercion. Rejected because constitution requires strict types.

Implementation Notes

  • Use readonly for all value objects (As2Message, Mdn, Partnership, Certificate, Transmission)
  • Use property hooks for validation in entities (Partnership, PartnershipConfiguration)
  • Use asymmetric visibility for state management (Transmission status, retry count)
  • Use intersection types for strategy interfaces (EncryptionStrategy & LoggerAwareInterface)
  • Use union types for flexible input parameters (string | StreamInterface)

Example: As2Message Value Object:

declare(strict_types=1);

namespace As2\Message;

readonly class As2Message
{
    public function __construct(
        public string $messageId,
        public string $as2From,
        public string $as2To,
        public string $as2Version,
        public string $contentType,
        public string $content,
        public bool $isSigned,
        public bool $isEncrypted,
        public ?string $mic = null,
        public ?string $micAlgorithm = null,
    ) {
        if (strlen($messageId) > 998) {
            throw new \InvalidArgumentException('Message-ID exceeds 998 characters');
        }
        if (empty($as2From) || empty($as2To)) {
            throw new \InvalidArgumentException('AS2-From and AS2-To are required');
        }
    }

    public function requiresMdn(): bool
    {
        return $this->mic !== null;
    }
}

Research Summary

All technical unknowns have been resolved:

  1. RFC 4130: Strict compliance requirements documented
  2. OpenSSL: All required PHP functions identified with examples
  3. MIME Structures: Parsing strategies defined for multipart/signed and application/pkcs7-mime
  4. PSR-7 Patterns: Wrapper classes designed for AS2-specific HTTP message handling
  5. Guzzle Configuration: Production-ready HttpClient with retries, timeouts, and error handling
  6. File Storage: High-performance indexing strategy with O(1) Message-ID lookups
  7. PHP 8.4 Features: Type system features identified for implementation (readonly, property hooks, asymmetric visibility)

Status: Research complete. Ready for Phase 1 design (data models, interface contracts, quickstart guide).