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.
Implement strict RFC 4130 compliance with all mandatory requirements and recommended features.
Mandatory HTTP Headers (Section 6.1):
AS2-Version: 1.0or1.1or1.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/signedwith two parts:- Content part (original message)
- 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:
- Human-readable text (optional)
message/disposition-notificationwith structured fields- 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
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.
- 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.
- Create
MessageHeadersclass to encapsulate header validation - Create
MimeStructureValidatorto verify multipart/signed and application/pkcs7-mime structure - Create
MdnBuilderto construct RFC-compliant MDNs - Use OpenSSL for all cryptographic operations (see section 2)
Use PHP's native OpenSSL extension functions for all S/MIME operations. No third-party cryptographic libraries.
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: UseOPENSSL_CIPHER_3DES,OPENSSL_CIPHER_AES_128_CBC,OPENSSL_CIPHER_AES_192_CBC, orOPENSSL_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_DETACHEDflag 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
truefor valid signature,falsefor invalid - Use
PKCS7_NOVERIFYto skip certificate chain validation (if trusting partner cert directly) - Use
PKCS7_DETACHEDfor detached signature verification
Certificate Loading:
openssl_x509_read(OpenSSLCertificate|string $certificate): OpenSSLCertificate|falseCertificate Parsing:
openssl_x509_parse(OpenSSLCertificate|string $certificate, bool $short_names = true): array|falseReturns array with:
subject: Distinguished nameissuer: Certificate authorityvalidFrom_time_t: Unix timestampvalidTo_time_t: Unix timestamppurposes: Key usage array
Private Key Loading:
openssl_pkey_get_private(
OpenSSLAsymmetricKey|OpenSSLCertificate|array|string $private_key,
?string $passphrase = null
): OpenSSLAsymmetricKey|falsePHP'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.
- phpseclib: Pure PHP cryptography library. Rejected because it's slower than native OpenSSL and adds unnecessary dependency.
- External OpenSSL CLI: Could shell out to
opensslcommand-line tool. Rejected due to portability concerns and performance overhead.
- 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);Use custom MIME parser for multipart/signed. Rely on OpenSSL for application/pkcs7-mime (encrypted data).
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:
- Parse boundary from Content-Type header
- Split message into parts using boundary
- First part: original content (before signing)
- Second part: detached signature
- Extract micalg (hash algorithm) from Content-Type header
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:
- Base64-decode the content
- Pass to
openssl_pkcs7_decrypt() - Decrypted output is the original message (which may be multipart/signed if signed+encrypted)
Sending (sender perspective):
- Sign original message → produces multipart/signed
- Encrypt entire multipart/signed message → produces application/pkcs7-mime
- Send application/pkcs7-mime via HTTP POST
Receiving (recipient perspective):
- Receive application/pkcs7-mime via HTTP POST
- Decrypt → yields multipart/signed
- Verify signature on multipart/signed → yields original message
- Compute MIC on original message
- Generate MDN with MIC
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.
- 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.
- Create
MimeParserclass with methods:parseMultipartSigned(string $message): array→ returns [content, signature, algorithm]extractBoundary(string $contentType): stringsplitParts(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]
];
}
}Use PSR-7 ServerRequestInterface for incoming messages and ResponseInterface for MDNs. Create AS2-specific wrapper classes for convenience.
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;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;
}
}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.
- 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.
- Use
guzzlehttp/psr7package for PSR-7 implementations (Response,Stream, etc.) - Create
As2Requestwrapper for incoming requests - Create
As2Responsebuilder for MDNs - All layer interfaces accept PSR-7 interfaces, not wrapper classes (wrappers are convenience only)
Wrap Guzzle client in HttpClient class with production-ready defaults: timeouts, retries, connection pooling, and error handling.
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
);
}
}
}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()
]);
}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.
- 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.
- HttpClient wrapper accepts optional
logger(PSR-3) for audit trail - Timeout configuration:
timeoutfor full request,connect_timeoutfor 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
Use hierarchical directory structure for messages with separate JSON index file for O(1) Message-ID lookups.
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
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"
}
}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.
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_idcolumn
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.
- 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.
- Use
LOCK_EXwhen 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)
Leverage PHP 8.4 features: strict types, readonly properties, property hooks, and asymmetric visibility for immutable value objects.
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 accessPublic 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.
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.
- 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.
- 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;
}
}All technical unknowns have been resolved:
- ✅ RFC 4130: Strict compliance requirements documented
- ✅ OpenSSL: All required PHP functions identified with examples
- ✅ MIME Structures: Parsing strategies defined for multipart/signed and application/pkcs7-mime
- ✅ PSR-7 Patterns: Wrapper classes designed for AS2-specific HTTP message handling
- ✅ Guzzle Configuration: Production-ready HttpClient with retries, timeouts, and error handling
- ✅ File Storage: High-performance indexing strategy with O(1) Message-ID lookups
- ✅ 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).