Deep Dive: ASN1\Constructed Objects
Overview
A \phpseclib4\File\ASN1\Constructed object (hereinafter referred to as Constructed) most typically corresponds to either an ASN.1 SEQUENCE or an ASN.1 SET and contains one or more child elements. They're essentially trees whose leaf nodes are non-Constructed instances of \phpseclib4\File\ASN1\Types\BaseType.
The following code sample shows how to load an X.509 certificate as a Constructed object:
use phpseclib4\File\ASN1;
use phpseclib4\File\ASN1\Maps\Certificate;
$x509 = ASN1::extractBER(file_get_contents('google.crt'));
$x509 = ASN1::decodeBER($x509);
$x509 = ASN1::map($x509, Certificate::MAP);
print_r($x509);
ASN1::extractBER() is only needed because the X.509 certificate is a base64-encoded PEM. If it were a DER (Distinguished Encoding Rules, a subset of the Basic Encoding Rules) the call would be unnecessary.
The output looks like this:
tbsCertificate
version
phpseclib4\File\ASN1\Types\Integer
serialNumber
phpseclib4\File\ASN1\Types\Integer
signature
algorithm
phpseclib4\File\ASN1\Types\OID
issuer
rdnSequence
0
0
type
phpseclib4\File\ASN1\Types\OID
value
phpseclib4\File\ASN1\Types\PrintableString
1
0
type
phpseclib4\File\ASN1\Types\OID
value
phpseclib4\File\ASN1\Types\PrintableString
2
0
type
phpseclib4\File\ASN1\Types\OID
value
phpseclib4\File\ASN1\Types\PrintableString
validity
notBefore
utcTime
phpseclib4\File\ASN1\Types\UTCTime
notAfter
utcTime
phpseclib4\File\ASN1\Types\UTCTime
subject
rdnSequence
0
0
type
phpseclib4\File\ASN1\Types\OID
value
phpseclib4\File\ASN1\Types\PrintableString
1
0
type
phpseclib4\File\ASN1\Types\OID
value
phpseclib4\File\ASN1\Types\PrintableString
2
0
type
phpseclib4\File\ASN1\Types\OID
value
phpseclib4\File\ASN1\Types\TeletexString
3
0
type
phpseclib4\File\ASN1\Types\OID
value
phpseclib4\File\ASN1\Types\TeletexString
4
0
type
phpseclib4\File\ASN1\Types\OID
value
phpseclib4\File\ASN1\Types\TeletexString
subjectPublicKeyInfo
algorithm
algorithm
phpseclib4\File\ASN1\Types\OID
subjectPublicKey
phpseclib4\File\ASN1\Types\BitString
extensions
0
extnId
phpseclib4\File\ASN1\Types\OID
critical
phpseclib4\File\ASN1\Types\Boolean
extnValue
phpseclib4\File\ASN1\Types\OctetString
1
extnId
phpseclib4\File\ASN1\Types\OID
critical
phpseclib4\File\ASN1\Types\Boolean
extnValue
phpseclib4\File\ASN1\Types\OctetString
2
extnId
phpseclib4\File\ASN1\Types\OID
critical
phpseclib4\File\ASN1\Types\Boolean
extnValue
phpseclib4\File\ASN1\Types\OctetString
3
extnId
phpseclib4\File\ASN1\Types\OID
critical
phpseclib4\File\ASN1\Types\Boolean
extnValue
phpseclib4\File\ASN1\Types\OctetString
signatureAlgorithm
algorithm
phpseclib4\File\ASN1\Types\OID
signature
phpseclib4\File\ASN1\Types\BitString
Superficially this looks identical to the X509: Reading Certificates example, but there are a few key differences:
-
Helper methods are not defined.
None of the helper methods (
getPublicKey(),listExtensions(),getExtension(),setExtension(), etc.) exist on a Constructed object. Constructed is intentionally generic and doesn't know enough about any specific data structure to define them. -
subjectPublicKey is a BitString.
$x509['tbsCertificate']['subjectPublicKeyInfo']['subjectPublicKey']is an instance of\phpseclib4\File\ASN1\Types\BitStringrather than aphpseclib4\Crypt\Common\PublicKey.Constructeddoesn't know how to decode the bit string into a structured public key. -
Extensions are OctetStrings.
None of the entries in
$x509['tbsCertificate']['extensions']are fleshed out. For example,$x509['tbsCertificate']['extensions'][1]['extnValue'](id-ce-cRLDistributionPoints) is a\phpseclib4\File\ASN1\Types\OctetStringrather than a parsed distribution-points structure - again, becauseConstructedhas no knowledge of individual extension formats. -
getEncoded() returns an empty string after changes.
Modifying any value invalidates the cached encoding, and
Constructedhas no way to regenerate it. See getEncoded() and Cache Invalidation for details. -
Unmapped substructures stay as Constructed.
When the schema marks a field as
ASN1::TYPE_ANY- meaning the bytes inside could be any valid ASN.1 structure - the decoder has no map to apply, so it leaves the field as a rawConstructed. This rarely surfaces in X.509 (mostANYfields there are leaf-shaped) but it's pervasive in CMS, where the format is built around content-type-tagged payloads that can only be decoded once you know which map to apply.
Despite these differences, phpseclib4\File\X509 is described as a thin wrapper around Constructed. That means both X509 - and any class built the same way - share the following features:
getEncoded()
getEncoded() returns the original BER-encoded bytes of an object. For our earlier X.509 certificate, that's just:
$x509->getEncoded();
The interesting use case is signature validation. Let's start with a CSR, since CSRs avoid the complications of extensions, validity periods, and the rest of what comes with X.509.
The straightforward path is $csr->validateSignature(). The manual equivalent looks like this:
use phpseclib4\File\CSR;
$csr = CSR::load(file_get_contents('csr.csr'));
$result = $csr->getPublicKey()->withHash('sha1')->verify(
$csr['certificationRequestInfo']->getEncoded(),
substr($csr['signature'], 1)
);
echo $result ? 'valid' : 'invalid';
The output is valid.
A few notes on the example:
-
CSR::load() is used rather than the raw ASN1 methods. The resultant CSR object exposes
getPublicKey()directly, which keeps the example short. -
The hash is hardcoded to 'sha1'. In principle it could be read from
$csr['signatureAlgorithm']['algorithm'], but that pattern doesn't generalize. With X.509 certificates, for example, the relevant algorithm lives on the ∗signing∗ certificate, not the one being verified - and at that point you should be using$x509->validateSignature()anyway. -
substr($csr['signature'], 1) strips the leading octet. Per X.690 § 8.6.2.2:
The initial octet shall encode, as an unsigned binary integer with bit 1 as the least significant bit, the number of unused bits in the final subsequent octet. The number shall be in the range zero to seven.
Strictly speaking the leading octet could be nonzero, but I've never seen a real-world DER signature where it is, and handling the general case would only bloat the example.
Cache Invalidation
What happens to ->getEncoded() if you change a value inside an already-encoded structure ultimately depends on which class owns the data.
Loading through ASN1:
use phpseclib4\File\ASN1;
use phpseclib4\File\ASN1\Maps\Certificate;
$x509 = ASN1::extractBER(file_get_contents('google.crt'));
$x509 = ASN1::decodeBER($x509);
$x509 = ASN1::map($x509, Certificate::MAP);
echo strlen($x509->getEncoded()) . "\n";
$x509['tbsCertificate']['serialNumber'] = new BigInteger('deadbeef', 16);
echo strlen($x509->getEncoded()) . "\n";
Output: 805, then 0.
Changing the serial number invalidates the cached encoding, and the resulting object - an instance of Constructed - has no machinery to regenerate it.
Loading through X509:
use phpseclib4\File\X509;
$x509 = X509::load(file_get_contents('google.crt'));
echo strlen($x509->getEncoded()) . "\n";
$x509['tbsCertificate']['serialNumber'] = new BigInteger('deadbeef', 16);
echo strlen($x509->getEncoded()) . "\n";
Output: 805, then 794.
The X509 class re-encodes changed sub-structures on every offsetGet() call.
Holding a reference into the structure:
use phpseclib4\File\X509;
$x509 = X509::load(file_get_contents('google.crt'));
$cert = &$x509['tbsCertificate'];
echo strlen($cert->getEncoded()) . "\n"; // outputs 654
$cert['serialNumber'] = new BigInteger('deadbeef', 16);
echo strlen($cert->getEncoded()) . "\n"; // outputs 0
echo strlen($x509['tbsCertificate']->getEncoded()) . "\n"; // outputs 643
$cert = &$x509['tbsCertificate'];
echo strlen($cert->getEncoded()) . "\n"; // outputs 643
Once you take a reference, you're operating on a Constructed directly, and the X509 re-encoding logic is bypassed. Going back through $x509['tbsCertificate'] re-enters X509::offsetGet() and the value is rebuilt; refreshing $cert then picks up the rebuilt copy.
Constructed: A Case Study covers the gotchas this caching creates, so it's fair to question why we even do it. Avoiding needless re-encoding is one reason, but the decisive one is signature validation. The CSR example above hints at it: $csr['certificationRequestInfo']->getEncoded() returns the bytes the signature was computed over, and those bytes must exactly match what the signer signed. Re-encoding doesn't always reproduce the original byte sequence - even under DER - so caching the original is what keeps that round trip reliable.
RFC 4055 pp. 6 gives a concrete example involving the AlgorithmIdentifier parameters of RSASSA-PSS and RSAES-OAEP:
[...] some implementations encode parameters as a NULL element while others omit them entirely. The correct encoding is to omit the parameters field; however, when RSASSA-PSS and RSAES-OAEP were defined, it was done using the NULL parameters rather than absent parameters.
All implementations MUST accept both NULL and absent parameters as legal and equivalent encodings.
Lazy and Eager Loading
By default, Constructed objects lazy loaded. This means the library identifies the boundaries of the ASN.1 structure but defers the actual decoding of values until they are accessed. Calling ->toArray() on a Constructed object will eager load it, converting the entire structure into a standard PHP array immediately.
To compare these approaches, we'll use bigcrl.bin, a 2.2MB CRL containing 41,326 revoked serial numbers, and we'll search for the 40,000th entry (120cd8).
The following function is used for all tests. The performance changes based on whether the input is an array or an object, and whether the unset() line is active (which the $optimize parameter toggles).
use phpseclib4\File\CRL;
use phpseclib4\Math\BigInteger;
function findSN(CRL|array $crl, BigInteger $sn, bool $optimize = false): ?int
{
$list = $crl['tbsCertList']['revokedCertificates'];
$total = count($list);
for ($i = 0; $i < $total; $i++) {
if ($list[$i]['userCertificate']->equals($sn)) {
return $i;
}
if ($optimize) {
// Immediately delete the decoded result to save RAM
unset($list[$i]->decoded);
}
}
return null;
}
Strategy 1: Eager Loading
This strategy decodes every single entry in the CRL before the search even begins.
$sn = new BigInteger('120cd8', 16);
$crl = CRL::load(file_get_contents('bigcrl.bin'));
$start = microtime(true);
$eagerCrl = $crl->toArray(); // Decode everything now
findSN($eagerCrl, $sn);
echo "Time: " . (microtime(true) - $start) . "s\n";
echo "Peak Memory: " . (memory_get_peak_usage() / 1024 / 1024) . "MB\n";
- Time: ~2.0s to load, 0.01s to search.
- Peak Memory: ~323MB
Strategy 2: Standard Lazy Loading
Here, phpseclib only decodes entries as the loop hits them. Once decoded, entries are cached for future access.
$crl = CRL::load(file_get_contents('bigcrl.bin'));
$start = microtime(true);
findSN($crl, $sn); // Lazy loading happens inside the loop
echo "Time: " . (microtime(true) - $start) . "s\n";
echo "Peak Memory: " . (memory_get_peak_usage() / 1024 / 1024) . "MB\n";
- Time: ~0.44s for the first search, ~0.08s for the second.
- Peak Memory: ~151MB
Strategy 3: Optimized Lazy Loading
By manually unsetting the decoded property, we prevent the cache from growing.
$crl = CRL::load(file_get_contents('bigcrl.bin'));
$start = microtime(true);
findSN($crl, $sn, true); // Use the optimize flag
echo "Time: " . (microtime(true) - $start) . "s\n";
echo "Peak Memory: " . (memory_get_peak_usage() / 1024 / 1024) . "MB\n";
- Time: ~0.44s for the first search, ~0.36s for the second.
- Peak Memory: ~43MB (less than PHP's default 128M memory limit)
Comparison Summary
Benchmarks performed on PHP 8.3.14
| Search | |||
|---|---|---|---|
| Eager Loading | 323mb | 2.01s | 0.01s |
| Lazy Loading | 151mb | 0.44s | 0.08s |
| Lazy Loading (with cache clearing) | 43mb | 0.44s | 0.36s |
| phpseclib v3 | 290mb | 1.30s | 0.01s |
Security Implications
phpseclib v1 - v3 utilize a two-step parsing process for ASN.1 structures. First, they fully decode the entire ASN.1 blob into a nested PHP array. Only after the entire structure is in memory does it attempt to map that data to a specific schema (such as an X.509 Certificate, CRL, or CMS).
This creates a potential denial-of-service vector. An attacker can craft a technically "valid" ASN.1 blob that is packed with high-complexity elements - like many large Object Identifiers (OIDs) - but is ultimately invalid as a certificate.
Example Attack Payload
use phpseclib4\File\ASN1;
use phpseclib4\Math\BigInteger;
$map = [
'type' => ASN1::TYPE_SEQUENCE,
'min' => 1,
'max' => -1,
'children' => ['type' => ASN1::TYPE_OBJECT_IDENTIFIER]
];
$raw = [];
for ($i = 0; $i < 50; $i++) {
// floor(3583 * 8 / 7) + 2 == 4096
$oid = '2.25.' . BigInteger::random(3583 << 3);
$raw[] = $oid;
}
$encoded = ASN1::encodeDER($raw, $map);
Prior to the security patches in 3.0.52, 2.0.54, and 1.0.29, attempting to load this payload via $x509->loadX509($encoded) could hang the CPU for 30 seconds or more. Even though the structure isn't a valid certificate, the "eager" parser decodes every malicious OID before the mapping phase fails.
The fix, for those versions of phpseclib, was to reduce the maximum allowed size for a single OID from 4096 bytes to 128 bytes, which is more than sufficient for any legitimate OID while preventing the bitwise shifting overhead from becoming a DoS vector.
In contrast, phpseclib v4 is immune to this class of issue by design because it decodes on demand.
When you call X509::load($encoded), the library does not decode the interior elements immediately. Instead, it creates a "map" of the structure's offsets. An OID is only decoded if the application explicitly attempts to access that specific field (e.g., fetching the signature algorithm).
With the above example payload, which does not match the structural template of an X509 certificate, an exception is thrown instantly before it ever reaches the OID decoding logic. This "fail-fast" behavior provides a massive security boost, as malicious interior data is never even processed.
In an OID like 1.2.840.10045.3.1.7, each number is called an arc.
Unlike a fixed-width integer, arc length is arbitrary. Using a Variable Length Quantity (Base 128) encoding, the parser must inspect the 8th bit of every byte:
- If it's 1, the arc continues to the next byte.
- If it's 0, the arc ends.
To calculate the final value, the parser must strip those 8th bits and "stitch" the remaining 7-bit chunks together. For massive, attacker-crafted OIDs, this requires intensive bit-shifting and BigInteger math. While most common arcs are small, the parser must be prepared for much larger values, like 128-bit UUIDs encoded under the 2.25 arc. Because these values exceed the 64-bit limit of native PHP integers, phpseclib uses BigInteger to ensure standards compliance and prevent overflow errors.
Error Concealment
A drawback of lazy loading is that errors can go unnoticed.
Consider this lazy-loaded X.509 certificate:
use phpseclib4\File\ASN1;
use phpseclib4\File\X509;
$x509 = file_get_contents('google.crt');
$x509 = ASN1::extractBER($x509);
$x509[100] = '.';
$x509 = X509::load($x509);
print_r($x509['tbsCertificate']['extensions'][1]->toArray());
The id-ce-cRLDistributionPoints extension is displayed without issue, despite the corruption introduced at byte 100.
Now consider the eagerly loaded equivalent:
use phpseclib4\File\ASN1;
use phpseclib4\File\X509;
$x509 = file_get_contents('google.crt');
$x509 = ASN1::extractBER($x509);
$x509[100] = '.';
print_r(X509::load($x509)->toArray());
This throws an exception.
With lazy loading, an exception is raised only if you actually touch the malformed part of the certificate. $x509['tbsCertificate']['extensions'][1]->toArray() succeeds because byte 100 falls outside that subtree; $x509['tbsCertificate']->toArray() fails because the corruption is somewhere within tbsCertificate.
But where, exactly? That's the subject of the next section.
Error Pinpointing
Consider the following code:
use phpseclib4\File\ASN1;
use phpseclib4\File\X509;
ASN1::enableBlobsOnBadDecodes();
$x509 = file_get_contents('google.crt');
$x509 = ASN1::extractBER($x509);
$x509[100] = '.';
$x509 = X509::load($x509)->toArray();
print_r($x509);
Without the ASN1::enableBlobsOnBadDecodes() call, this would throw an exception. With it, we get:
tbsCertificate
version
phpseclib4\File\ASN1\Types\Integer
serialNumber
phpseclib4\File\ASN1\Types\Integer
signature
algorithm
phpseclib4\File\ASN1\Types\OID
issuer
phpseclib4\File\ASN1\MalformedData
validity
notBefore
utcTime
phpseclib4\File\ASN1\Types\UTCTime
notAfter
utcTime
phpseclib4\File\ASN1\Types\UTCTime
subject
rdnSequence
0
0
type
phpseclib4\File\ASN1\Types\OID
value
phpseclib4\File\ASN1\Types\PrintableString
1
0
type
phpseclib4\File\ASN1\Types\OID
value
phpseclib4\File\ASN1\Types\PrintableString
2
0
type
phpseclib4\File\ASN1\Types\OID
value
phpseclib4\File\ASN1\Types\TeletexString
3
0
type
phpseclib4\File\ASN1\Types\OID
value
phpseclib4\File\ASN1\Types\TeletexString
4
0
type
phpseclib4\File\ASN1\Types\OID
value
phpseclib4\File\ASN1\Types\TeletexString
subjectPublicKeyInfo
phpseclib4\Crypt\RSA\PublicKey
-----BEGIN PUBLIC KEY----- MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDetyZDppmFzTinFQm5zw/Jw1WM iO6MjSgnJEsqXqDYFvphGEvPbWCA0zVAMnLAjxLY5U6PubL22RVeWoYxo7qGqmvI 2XGMzM0nEx6dQl049qes7/pi8xiB1CRGfwF3fMYqiRSZu5g5HagZ+zkARH0blGp4 LWmtwHos+tDaIBKY0wIDAQAB -----END PUBLIC KEY-----
extensions
0
extnId
phpseclib4\File\ASN1\Types\OID
critical
phpseclib4\File\ASN1\Types\Boolean
extnValue
cA
phpseclib4\File\ASN1\Types\Boolean
1
extnId
phpseclib4\File\ASN1\Types\OID
critical
phpseclib4\File\ASN1\Types\Boolean
extnValue
0
distributionPoint
fullName
0
uniformResourceIdentifier
phpseclib4\File\ASN1\Types\IA5String
2
extnId
phpseclib4\File\ASN1\Types\OID
critical
phpseclib4\File\ASN1\Types\Boolean
extnValue
0
phpseclib4\File\ASN1\Types\OID
1
phpseclib4\File\ASN1\Types\OID
2
phpseclib4\File\ASN1\Types\OID
3
extnId
phpseclib4\File\ASN1\Types\OID
critical
phpseclib4\File\ASN1\Types\Boolean
extnValue
0
accessMethod
phpseclib4\File\ASN1\Types\OID
accessLocation
uniformResourceIdentifier
phpseclib4\File\ASN1\Types\IA5String
1
accessMethod
phpseclib4\File\ASN1\Types\OID
accessLocation
uniformResourceIdentifier
phpseclib4\File\ASN1\Types\IA5String
signatureAlgorithm
algorithm
phpseclib4\File\ASN1\Types\OID
signature
phpseclib4\File\ASN1\Types\BitString
$x509['tbsCertificate']['issuer'] is now an instance of phpseclib4\File\ASN1\MalformedData - pinpointing exactly where the corruption is.
What MalformedData Can and Can't Tell You
The example above is the happy path: one corrupted field, cleanly identified. MalformedData is genuinely useful, but it's a narrower tool than "show me everything wrong with this file," and it's worth understanding why before you rely on it.
The mental model that predicts its behavior is this: the decoder is trying as hard as it can to fit the bytes into the shape it was given, and it will never invent a new shape. Everything below follows from that.
A blob needs a key to live under
MalformedData is substituted into the mapped structure, which means it can only appear where the map has a slot to put it. $x509['tbsCertificate']['issuer'] worked in the example above because issuer is a field the map knows about.
Data that fails to decode but doesn't correspond to any expected field has no array key to be associated with, so it simply doesn't appear. There is no [unexpected data] entry in the output - extra bytes are silently absent rather than flagged.
The degenerate case is an element with no valid tag or length at all, which can't be delimited even as a blob:
use phpseclib4\File\{ASN1\Element, X509};
$x509 = new X509();
$x509['tbsCertificate']['extensions'] = new Element('zzzzzzzzzzzz');
$x509 = "$x509";
X509::load($x509)->toArray(); // throws UnexpectedValueException
Element('zzzzzzzzzzzz') has no valid tag and - more importantly - no valid length, so the parser has nothing to measure. ASN1::enableBlobsOnBadDecodes() doesn't help here.
A type mismatch can silently truncate everything after it
When the map expects one tag and the encoding has another, the decoder doesn't blob the mismatch. It treats the element as not a match, fails to find what it wanted, and stops - and because the unmatched element has no key to map to, it isn't reported either. Enabling blobs mode does not change this.
Here's a Kerberos AS-REP where crealm is declared as an OCTET STRING but the encoding actually uses a GENERALSTRING:
use phpseclib4\File\ASN1;
$KDC_REP = [
'type' => ASN1::TYPE_SEQUENCE,
'children' => [
'pvno' => [
'constant' => 0,
'optional' => true,
'explicit' => true,
'type' => ASN1::TYPE_INTEGER,
],
'msg-type' => [
'constant' => 1,
'optional' => true,
'explicit' => true,
'type' => ASN1::TYPE_INTEGER,
],
'padata' => [
'constant' => 2,
'optional' => true,
'explicit' => true,
'min' => 0,
'max' => -1,
'type' => ASN1::TYPE_SEQUENCE,
'children' => $PA_DATA,
],
'crealm' => [ // the DER has a GENERALSTRING here, not an OCTET STRING
'constant' => 3,
'optional' => true,
'explicit' => true,
'type' => ASN1::TYPE_OCTET_STRING,
],
// ... cname, ticket, enc-part ...
],
];
ASN1::enableBlobsOnBadDecodes();
$decoded = ASN1::decodeBER($der);
print_r(ASN1::map($decoded, $AS_REP)->toArray());
The output contains pvno, msg-type and padata - and then nothing. The decoder never finds an OCTET STRING under an explicit cont [3], so it considers cont [3] to be extra data, stops, and never looks at cname, ticket or enc-part.
Note what the diagnostic signal is in this case. There's no MalformedData anywhere. The clue is that the output is short: the last field that decoded successfully sits immediately before the mismatch.
toArray() is not a transcript of the DER
toArray() and __debugInfo() render the mapped view, and part of mapping is populating defaults for fields the schema declares a default for. Those defaults are written into the array output rather than being applied silently behind the scenes.
That has a confusing consequence when a field fails to decode: the decoder skips ahead looking for something that does match, and a skipped-over field with a default shows up carrying that default.
use phpseclib4\File\{ASN1, ASN1\Element, X509};
$x509 = new X509();
$x509['tbsCertificate']['version'] = new Element('zzzzzzz');
$x509 = "$x509";
ASN1::enableBlobsOnBadDecodes();
print_r(X509::load($x509)->toArray());
Array
(
[tbsCertificate] => Array
(
[version] => phpseclib4\File\ASN1\Types\Integer Object
(
[value] => v1
)
[serialNumber] => phpseclib4\File\ASN1\MalformedData Object
(
[value] => 7a7a7a7a7a7a7a
)
[signature] => phpseclib4\File\ASN1\MalformedData Object
(
[value] => 34323437393530343431303534333239363136...
)
[issuer] => phpseclib4\File\ASN1\MalformedData Object
(
[value] => 060100
)
[validity] => Array
(
)
...
)
...
)
version reads as v1 despite having been set to zzzzzzz, because the decoder couldn't read it, moved on until it found a matching element, and then filled the slot with the schema default.
Notice also that the corruption shifts the fields after it: the 7a7a… bytes surface under serialNumber, not under version. MalformedData tells you roughly where the trouble starts, not precisely which field was corrupted.
Because defaults are materialized into the array, you can't conclude that a field was present in the underlying DER just because it appears in toArray() output. If you need to know what's actually encoded, look at the bytes.
Cross-Checking with openssl asn1parse
MalformedData is one instrument, not the whole panel. The natural second opinion is a schema-less structural dump, and openssl asn1parse is the usual choice:
openssl asn1parse -in test.pem -inform DER
For the Kerberos example above, the way to find the bug is to look for cont [3] at a depth of 2:
67:d=2 hl=2 l= 15 cons: cont [ 3 ]
69:d=3 hl=2 l= 13 prim: GENERALSTRING
which shows immediately that the field is a GENERALSTRING, not the OCTET STRING the map declared.
The two tools cover different failure modes, and the split follows from where each one sits in the pipeline. OpenSSL, like phpseclib, works in two passes: first it decodes the ASN.1 tag/length structure, then it maps that structure onto X.509 (or whatever the target schema is). asn1parse exposes only the first pass. So:
- When the ASN.1 decode itself fails - bad tags, bogus lengths, truncated elements -
asn1parseis failing on the same pass and has little to show you. This is whereMalformedDataearns its keep: phpseclib keeps going and hands you the surrounding structure plus the raw bytes of the part that didn't decode. - When the ASN.1 decodes cleanly but doesn't fit the schema - wrong type, missing required field, unexpected tag -
asn1parseis likely the easier tool, because the structure it prints is complete and correct and you only have to compare it against what the map expects. This is also exactly the case where blobs mode is least informative, per the truncation behavior described above.
Staying inside PHP, a schema-less ASN1::decodeBER() followed by toArray() is the closest analogue to asn1parse - phpseclib's own view of the raw tag structure with no mapping imposed on it.
Bypassing Encoding with ASN1\Element
Everything discussed so far has been about reading ASN.1 - lazy decoding, error pinpointing, walking a structure that phpseclib already understands. There's a corresponding tool for the write side: \phpseclib4\File\ASN1\Element.
Element is a wrapper that tells phpseclib "these bytes are already encoded - don't touch them." Anywhere phpseclib would otherwise encode a value (a DN, an extension value, even a top-level field via ArrayAccess), an Element is written through verbatim. That makes it the universal escape hatch for:
- Writing custom extensions without registering them.
X509::registerExtension()is the standard path for custom extensions and gives you structured read access in addition to write support.Elementis the shorter path when you only need to write and don't care about structured read-back - eg. producing a fixture cert. Encode the bytes yourself, wrap them inElement, pass tosetExtension(). - Fuzzing parsers. Real-world parsers disagree about ASN.1 edge cases, and you may want to produce intentionally-malformed certs to see how each one reacts.
Elementlets you put arbitrary bytes anywhere, including bytes that don't decode as valid ASN.1 or bytes whose outer ASN.1 type is wrong for the slot. - Interop testing against external implementations that have their own opinion about a structure.
Fuzzing with malformed values
Because Element bypasses encoding, you can put anything in any slot - including bytes that violate the schema for that field. As an extreme example, here's how to swap a certificate's signature from a BIT STRING to an OCTET STRING to see how downstream parsers react:
use phpseclib4\File\{ASN1, ASN1\Element, X509};
$x509 = X509::load($pem);
// Replace the signature with the same bytes but tagged as OCTET STRING:
$x509['signature'] = new Element(
ASN1::encodeDER("$x509[signature]", ['type' => ASN1::TYPE_OCTET_STRING])
);
echo $x509;
// Outputs a PEM-encoded cert whose signature field is the wrong ASN.1 type.
// Most parsers will reject it; some will silently accept it; some will crash.
// Useful data either way.
This composes with the Error Pinpointing tooling on the read side: produce a deliberately-broken cert with Element, hand it to X509::load(), then turn on ASN1::enableBlobsOnBadDecodes() to see exactly which slot a strict parser objects to.
Use Element sparingly in production code - you lose all of phpseclib's validation - but it's the right tool for fuzzing, interop testing, and one-off custom-structure work where registration would be overkill.
Array-Like Interface
Despite being objects, Constructed instances expose their dynamic elements through array syntax - e.g., $x509['tbsCertificate']['extensions']. This is because Constructed implements the ArrayAccess interface.
It also implements Countable and Iterator, so count($x509['tbsCertificate']['extensions']) works as expected and foreach iterates over child elements directly.
What Constructed does not implement is ArrayObject. PHP can do many things with arrays that don't translate sensibly to a Constructed - it's unclear, for example, what it would mean to sort() one - so those operations are deliberately left out.
In the other direction, there are array operations that would make sense for Constructed but for which PHP exposes no overridable hook. The most notable is array_keys(); the closest equivalent is the ->keys() method on the object itself.
Wrapping Constructed
As noted earlier, Constructed objects are deliberately bare-bones. If you want to build a class that adds higher-level conveniences on top of one - the way X509 does - there's a walkthrough at Constructed: A Case Study.