Skip to main content
Version: 4.0

Constructed - A Case Study

Constructed is the generic backbone that every higher-level phpseclib class - X509, CRL, CSR, the various CMS classes - is built on top of. This page walks through building one of those wrappers from scratch: a simplified SignedData class for CMS\SignedData.

Two things are worth setting expectations on before we start:

  1. The walkthrough is iterative. Each section adds one capability, and most sections introduce a new gotcha that the next section fixes. By the end you'll have a working class and a clear picture of where the rough edges are.
  2. The current API for building on top of Constructed is more low-level than it should be. Several of the "fixes" along the way are workarounds rather than principled solutions. The Open Issues section at the end collects these in one place.

The Skeleton

Every wrapper around Constructed needs the same plumbing: array-style access (ArrayAccess), iteration (Iterator), counting (Countable), and pass-throughs for the methods Constructed exposes (getEncoded(), toArray(), __debugInfo()). Almost all of this is straightforward delegation to an internal field - we'll call it $cms - that holds either a Constructed (when loaded from bytes) or a plain array (when constructed in memory).

There's also a compile() method, currently empty. Think of it as a lazy-normalization hook: when $cms is in a state that callers shouldn't see directly (like a raw array that hasn't been through the encoder yet), compile() is responsible for fixing that up before the call goes through. We'll fill it in once we have a concrete reason to.

class SignedData implements \ArrayAccess, \Countable, \Iterator
{
private array|Constructed $cms;

public function __debugInfo(): array
{
$this->compile();
return $this->cms->__debugInfo();
}

public function toArray(): array
{
$this->compile();
return $this->cms->toArray();
}

public function &offsetGet(mixed $offset): mixed
{
return $this->cms[$offset];
}

public function offsetExists(mixed $offset): bool
{
return isset($this->cms[$offset]);
}

public function offsetSet(mixed $offset, mixed $value): void
{
$this->cms[$offset] = $value;
}

public function offsetUnset(mixed $offset): void
{
unset($this->cms[$offset]);
}

public function count(): int
{
return is_array($this->cms) ? count($this->cms) : $this->cms->count();
}

public function rewind(): void
{
$this->compile();
$this->cms->rewind();
}

public function current(): mixed
{
$this->compile();
return $this->cms->current();
}

public function key(): mixed
{
$this->compile();
return $this->cms->key();
}

public function next(): void
{
$this->compile();
$this->cms->next();
}

public function valid(): bool
{
$this->compile();
return $this->cms->valid();
}

public function getEncoded(): string
{
$this->compile();
return $this->cms->getEncoded();
}

public function hasEncoded(): bool
{
$this->compile();
return $this->cms->hasEncoded();
}

private function compile(): void
{
// currently does nothing
}
}

That's the foundation. The next four sections build on top of it.

Loading from Bytes

Step 1: Decoding the outer structure

A SignedData CMS is a ContentInfo wrapping a SignedData payload. The natural first attempt is to apply the ContentInfo map and call it done:

    public static function load(string $cms): self
{
$result = new self();
$decoded = ASN1::decodeBER(ASN1::extractBER($cms));
$result->cms = ASN1::map($decoded, Maps\ContentInfo::MAP);
return $result;
}

ASN1::extractBER() strips the PEM wrapper if there is one, ASN1::decodeBER() does a structural pass, and ASN1::map() applies the schema.

Calling print_r(SignedData::load(file_get_contents('signed.p7m'))) (download signed.p7m) gives this:

contentType
phpseclib4\File\ASN1\Types\OID
id-envelopedData
content
phpseclib4\File\ASN1\Element
...

The outer structure decoded fine, but content is still an opaque blob. That's because in Maps\ContentInfo::MAP, content is typed as ASN1::TYPE_ANY - the schema deliberately leaves it unspecified so the same ContentInfo map can wrap any payload type.

Step 2: Decoding the inner SignedData

Once we know we're looking at SignedData, we can decode the inner content with the matching map:

    public static function load(string $cms): self
{
$result = new self();
$decoded = ASN1::decodeBER(ASN1::extractBER($cms));
$result->cms = ASN1::map($decoded, Maps\ContentInfo::MAP);
$decoded = ASN1::decodeBER($result->cms['content']);
$result->cms['content'] = ASN1::map($decoded, Maps\SignedData::MAP);
return $result;
}

print_r now shows the full structure:

contentType
phpseclib4\File\ASN1\Types\OID
id-signedData
content
version
phpseclib4\File\ASN1\Types\Integer
v1
digestAlgorithms
0
algorithm
phpseclib4\File\ASN1\Types\OID
id-sha256
encapContentInfo
eContentType
phpseclib4\File\ASN1\Types\OID
id-data
eContent
certificates
0
certificate
tbsCertificate
version
phpseclib4\File\ASN1\Types\Integer
v3
serialNumber
phpseclib4\File\ASN1\Types\Integer
2764036
signature
algorithm
phpseclib4\File\ASN1\Types\OID
sha1WithRSAEncryption
issuer
rdnSequence
0
0
type
phpseclib4\File\ASN1\Types\OID
id-at-countryName
value
phpseclib4\File\ASN1\Types\PrintableString
IT
1
0
type
phpseclib4\File\ASN1\Types\OID
id-at-organizationName
value
phpseclib4\File\ASN1\Types\PrintableString
I.T. Telecom S.R.L.
2
0
type
phpseclib4\File\ASN1\Types\OID
id-at-organizationalUnitName
value
phpseclib4\File\ASN1\Types\PrintableString
Servizi di certificazione
3
0
type
phpseclib4\File\ASN1\Types\OID
id-at-commonName
value
phpseclib4\File\ASN1\Types\PrintableString
Regione Lombardia Certification Authority Cittadini
validity
notBefore
utcTime
phpseclib4\File\ASN1\Types\UTCTime
2008-10-18 00:00:00
notAfter
utcTime
phpseclib4\File\ASN1\Types\UTCTime
2014-10-15 00:00:00
subject
rdnSequence
0
0
type
phpseclib4\File\ASN1\Types\OID
id-at-countryName
value
phpseclib4\File\ASN1\Types\PrintableString
IT
1
0
type
phpseclib4\File\ASN1\Types\OID
id-at-organizationName
value
phpseclib4\File\ASN1\Types\PrintableString
CRS-SISS
2
0
type
phpseclib4\File\ASN1\Types\OID
id-at-organizationalUnitName
value
phpseclib4\File\ASN1\Types\PrintableString
Regione Lombardia
3
0
type
phpseclib4\File\ASN1\Types\OID
id-at-commonName
value
phpseclib4\File\ASN1\Types\PrintableString
DLGMRC65L15A794Y/6030601293335002.8ozBTogo6/gti/2/m8PJpZlm74c=
subjectPublicKeyInfo
algorithm
algorithm
phpseclib4\File\ASN1\Types\OID
rsaEncryption
subjectPublicKey
phpseclib4\File\ASN1\Types\BitString
0030818902818100abaa134ee0c6ad3d5847ebd0d05a5cb170b0a60d4f253d577b2531c3554a80f7735fad8912ad77c2964e168071c3dd7528841bea26fe690df709f4feefec9da84d853c485c18b657ca3d933c55a8660628d3faf8550366b3948182f2d54d178fdfbddc2bf84af8bb4ac36f02ac8292f73633c7af8f519217fa8919ed13135baf0203010001
extensions
0
extnId
phpseclib4\File\ASN1\Types\OID
id-ce-certificatePolicies
critical
phpseclib4\File\ASN1\Types\Boolean
false
extnValue
phpseclib4\File\ASN1\Types\OctetString
3082011b3081da06052b4c1002013081d030819e06082b060105050702023081911a818e4964656e74696669657320582e3530392061757468656e7469636174696f6e206365727469666963617465732069737375656420666f7220746865206974616c69616e204e6174696f6e616c205365727669636520436172642028434e53292070726f6a65637420696e206163636f7264696e6720746f20746865206974616c69616e20726567756c6174696f6e302d06082b06010505070201162168747470733a2f2f7777772e7469706b692e69742f524c4341434954542f434e53303c06092b4c0c01010a02020a302f302d06082b06010505070201162168747470733a2f2f7777772e7469706b692e69742f524c4341434954542f435053
1
extnId
phpseclib4\File\ASN1\Types\OID
id-pe-authorityInfoAccess
critical
phpseclib4\File\ASN1\Types\Boolean
false
extnValue
phpseclib4\File\ASN1\Types\OctetString
302a302806082b06010505073001861c687474703a2f2f6f6373702e637273736973732e7469706b692e6974
2
extnId
phpseclib4\File\ASN1\Types\OID
id-ce-keyUsage
critical
phpseclib4\File\ASN1\Types\Boolean
true
extnValue
phpseclib4\File\ASN1\Types\OctetString
03020780
3
extnId
phpseclib4\File\ASN1\Types\OID
id-ce-extKeyUsage
critical
phpseclib4\File\ASN1\Types\Boolean
false
extnValue
phpseclib4\File\ASN1\Types\OctetString
300a06082b06010505070302
4
extnId
phpseclib4\File\ASN1\Types\OID
id-ce-authorityKeyIdentifier
critical
phpseclib4\File\ASN1\Types\Boolean
false
extnValue
phpseclib4\File\ASN1\Types\OctetString
30168014089f783b11c5f2f9616681025f8a84a3271c1c25
5
extnId
phpseclib4\File\ASN1\Types\OID
id-ce-cRLDistributionPoints
critical
phpseclib4\File\ASN1\Types\Boolean
false
extnValue
phpseclib4\File\ASN1\Types\OctetString
3081ca3027a025a0238621687474703a2f2f6364702e726c2e7469706b692e69742f43524c2f4343524c313630819ea0819ba081988681956c6461703a2f2f6c6461702e7469706b692e69742f636e2533644343524c31362c6f752533644341253230436974746164696e692c6f253364526567696f6e652532304c6f6d6261726469612c6325336449543f63657274696669636174655265766f636174696f6e4c6973743f626173653f286f626a656374436c6173733d63524c446973747269627574696f6e506f696e7429
6
extnId
phpseclib4\File\ASN1\Types\OID
id-ce-subjectKeyIdentifier
critical
phpseclib4\File\ASN1\Types\Boolean
false
extnValue
phpseclib4\File\ASN1\Types\OctetString
04140eaa7ae6f23a0649fd101fc05557cf06cc2c45d2
signatureAlgorithm
algorithm
phpseclib4\File\ASN1\Types\OID
sha1WithRSAEncryption
signature
phpseclib4\File\ASN1\Types\BitString
00359bbb050df84aa596e82de896e69de2114a61fbff93157a8dd8617cd286a1027179a367f3a02ba27b5b357b3da9bb3c7ccda8abbd083caa6b684b540f109c290790336325254030bdf3daade0287ee5e927ecb2bf478c6eb2a063504705e6ca85daab7d8bfd68877a9ecd3484cfa73c68e4d1c284b87cd5cad4a8fe9a0bfc0577aeb2261826e5ba1b70076a25fe354d64a861264f3c95b1ce14acd432866e7f9b275c33a0520904b1588fd8a59ad0811708b8f7ccda2d2d6860522f3327541afbdf17e383ed58a3f491c66c69b61593e87430203474f188b83490d80734d62ee751a407867212664da2e97d6f37dfc56df93c52536c4811902594e61d26b1b6
signerInfos
0
version
phpseclib4\File\ASN1\Types\Integer
v1
sid
issuerAndSerialNumber
issuer
rdnSequence
0
0
type
phpseclib4\File\ASN1\Types\OID
id-at-countryName
value
phpseclib4\File\ASN1\Types\PrintableString
IT
1
0
type
phpseclib4\File\ASN1\Types\OID
id-at-organizationName
value
phpseclib4\File\ASN1\Types\PrintableString
I.T. Telecom S.R.L.
2
0
type
phpseclib4\File\ASN1\Types\OID
id-at-organizationalUnitName
value
phpseclib4\File\ASN1\Types\PrintableString
Servizi di certificazione
3
0
type
phpseclib4\File\ASN1\Types\OID
id-at-commonName
value
phpseclib4\File\ASN1\Types\PrintableString
Regione Lombardia Certification Authority Cittadini
serialNumber
phpseclib4\File\ASN1\Types\Integer
2764036
digestAlgorithm
algorithm
phpseclib4\File\ASN1\Types\OID
id-sha256
signedAttrs
0
type
phpseclib4\File\ASN1\Types\OID
id-contentType
value
0
phpseclib4\File\ASN1\Types\OID
id-data
1
type
phpseclib4\File\ASN1\Types\OID
id-signingTime
value
0
phpseclib4\File\ASN1\Types\UTCTime
2014-08-19 16:21:30
2
type
phpseclib4\File\ASN1\Types\OID
id-messageDigest
value
0
phpseclib4\File\ASN1\Types\OctetString
ee180eea75b4a945a160c0781b9ebdbc144365615c0f5604411b9b2175e791e9
3
type
phpseclib4\File\ASN1\Types\OID
id-aa-signingCertificateV2
value
0
phpseclib4\File\ASN1\Element
3081c63081c33081c00420a247da372573393067cbbba7a535c804861b94a2575ef8ad5adf30b13a0638a830819b308193a4819030818d310b3009060355040613024954311c301a060355040a1313492e542e2054656c65636f6d20532e522e4c2e31223020060355040b131953657276697a69206469206365727469666963617a696f6e65313c303a06035504031333526567696f6e65204c6f6d6261726469612043657274696669636174696f6e20417574686f7269747920436974746164696e6902032a2d04
signatureAlgorithm
algorithm
phpseclib4\File\ASN1\Types\OID
rsaEncryption
signature
phpseclib4\File\ASN1\Types\OctetString
0f7c375e5ff7306c280912b76f9eb11470d8d0731c4dc0f013e605e3cea74376a7474664e6095c72d2ef2a5bb356a05bbec1a2364008846aa101a9160676ca6bdb989e60c012266fe25d51ead5c8d71926e0504f3cbabca9ae6e9e05318905597ed0230adbb3fe0f3f19b96050fcf05def9fab3bd1e575cc8a33ab1a16fcce25

That looks much better, and because the wrapper is built on Constructed, we get array access, iteration, counting, and getEncoded() for free.

Step 3: Preserving the cached encoding

There's a subtle problem with what we just did. With the original load() method, strlen($cms->getEncoded()) returned 14223. With the new one, it returns 0.

The reason: assigning to $result->cms['content'] invalidated the cached encoding (see Cache Invalidation on the previous page). From Constructed's perspective, we just modified the structure, and the cached bytes no longer reflect what's in memory.

But we know the assignment didn't actually change anything - we replaced the inner blob with a decoded version of the same bytes. The fix is to suspend cache invalidation during the swap:

    public static function load(string $cms): self
{
$result = new self();
$decoded = ASN1::decodeBER(ASN1::extractBER($cms));
$result->cms = ASN1::map($decoded, Maps\ContentInfo::MAP);
$decoded = ASN1::decodeBER($result->cms['content']);
ASN1::disableCacheInvalidation();
$result->cms['content'] = ASN1::map($decoded, Maps\SignedData::MAP);
ASN1::enableCacheInvalidation();
return $result;
}

getEncoded() now returns 14223 again.

Step 4: Wiring up the parent pointer

One more thing to fix. Try modifying the loaded structure:

$cms = SignedData::load(file_get_contents('signed.p7m'));
echo strlen($cms->getEncoded()) . "\n";
unset($cms['content']['signerInfos']);
echo strlen($cms->getEncoded()) . "\n";

Both lines print 14223 - even though we just deleted signerInfos from the inner structure.

The problem is in how cache invalidation propagates. When a child Constructed is modified, it walks up the parent chain invalidating each ancestor's cache. But the inner SignedData we attached in step 2 has no parent pointer - ASN1::map() doesn't know about the surrounding ContentInfo. So the walk stops at $cms['content'] and never reaches the outer object.

We have to wire the parent pointer manually:

    public static function load(string $cms): self
{
$result = new self();
$decoded = ASN1::decodeBER(ASN1::extractBER($cms));
$result->cms = ASN1::map($decoded, Maps\ContentInfo::MAP);
$decoded = ASN1::decodeBER($result->cms['content']);
ASN1::disableCacheInvalidation();
$result->cms['content'] = ASN1::map($decoded, Maps\SignedData::MAP);
$result->cms['content']->parent = $result->cms;
$result->cms['content']->key = 'parent';
$result->cms['content']->depth = $result->cms->depth;
ASN1::enableCacheInvalidation();
return $result;
}

key and depth aren't strictly required for cache invalidation in this example, but setting them keeps the tree consistent and avoids surprises elsewhere.

Loading is now correct. On to constructing from scratch.

Constructing in Memory

Loading from bytes is one entry point; the other is building a CMS from nothing. Here's a constructor that takes a payload and assembles the surrounding structure:

    public function __construct(string $data)
{
$this->cms = [
'contentType' => 'id-signedData',
'content' => [
'version' => 'v1',
'digestAlgorithms' => [],
'encapContentInfo' => [
'eContentType' => 'id-data',
'eContent' => $data
],
'signerInfos' => []
]
];
}

Two things about this are worth pointing out.

load() and __construct() now disagree on signature. load() calls new self() with no arguments; the constructor now requires $data. We could special-case it (new self('')), but that pattern doesn't generalize - a class with no sensible default for its constructor argument has nothing to fall back on. The reliable answer is to bypass the constructor entirely in load():

$r = new \ReflectionClass(__CLASS__);
$result = $r->newInstanceWithoutConstructor();

The constructor builds a plain array, not Constructed objects. Beyond the syntactic convenience, this is forced by how Constructed works: a Constructed object represents bytes that have been (or will be) parsed by the ASN.1 decoder, and you can't directly instantiate one from a literal. Arrays are the natural input format for the encoder, so we keep things in array form until we need otherwise.

The trade-off is that $this->cms is now sometimes an array and sometimes a Constructed, and most of the wrapper's methods only know how to talk to the latter. If you do this:

$cms = new SignedData('hello, world!');
print_r($cms);

you get an error.

This is where compile() finally earns its keep. Its job is to convert the array form into a Constructed on demand, so that callers always see a uniform interface:

    private function compile(): void
{
if (!$this->cms instanceof Constructed) {
$temp = [
'contentType' => $this->cms['contentType'],
'content' => new Element(ASN1::encodeDER($this->cms['content'], Maps\SignedData::MAP)),
];
$cms = ASN1::encodeDER($temp, Maps\ContentInfo::MAP);
$this->cms = self::load($cms)->cms;
return;
}
if (!$this->cms->hasEncoded()) {
ASN1::encodeDER($this->cms['content'], Maps\SignedData::MAP);
$cms = ASN1::encodeDER($this->cms, Maps\ContentInfo::MAP);
$temp = self::load($cms);
$this->cms = $temp->cms;
}
}

The first branch handles the array-form case: encode the inner SignedData, wrap it in a ContentInfo, then load() the result so we end up with a properly-wired Constructed tree. The second branch handles the case where $this->cms is already a Constructed but lacks a cached encoding (e.g., after a modification) - the same load()-after-encode round trip refreshes the tree.

Why bounce through load() instead of just keeping the array? Because everything we did in the loading section - the cache wiring, the parent pointer - lives in load(). Funneling new objects through it means we don't have to duplicate any of that logic.

After compile():

$cms = new SignedData('hello, world!');
echo strlen($cms->getEncoded()); // 54
print_r($cms); // shows the same structure as a loaded CMS

You might wonder why we don't just special-case __debugInfo() to handle the array case directly. We could, but it would diverge from how a loaded CMS looks, and it wouldn't help getEncoded(), count(), iteration, or anything else. compile() solves all of them at once.

Replacing Children with Custom Classes

Up to this point, every value in the tree is either a Constructed or a leaf type. But suppose we want $cms['content']['signerInfos'][0] to be more than just a generic Constructed - we want to call validateSignature() on it, which means we need a Signer class with that method.

The mechanism for this is ASN1::map()'s third parameter: a rules array that lets you intercept specific fields during decoding and replace them with whatever you want.

The Signer class

Signer reuses the same skeleton as SignedData, with three small changes: the field is $signer instead of $cms, it's public so SignedData can wire up parent pointers from outside, and load() operates on a SignerInfo map rather than a ContentInfo map:

    public static function load(string $encoded): self
{
$r = new \ReflectionClass(__CLASS__);
$result = $r->newInstanceWithoutConstructor();
$decoded = ASN1::decodeBER($encoded);
$result->signer = ASN1::map($decoded, Maps\SignerInfo::MAP);
return $result;
}

Hooking it into SignedData::load()

We pass a rule for the signerInfos field that points at a method on SignedData:

    public static function load(string $cms): self
{
$r = new \ReflectionClass(__CLASS__);
$result = $r->newInstanceWithoutConstructor();
$decoded = ASN1::decodeBER(ASN1::extractBER($cms));
$result->cms = ASN1::map($decoded, Maps\ContentInfo::MAP);
$decoded = ASN1::decodeBER($result->cms['content']);
ASN1::disableCacheInvalidation();
$rules = [];
$rules['signerInfos'] = [self::class, 'mapInSigners'];
$result->cms['content'] = ASN1::map($decoded, Maps\SignedData::MAP, $rules);
$result->cms['content']->parent = $result->cms;
$result->cms['content']->key = 'parent';
$result->cms['content']->depth = $result->cms->depth;
ASN1::enableCacheInvalidation();
return $result;
}

The mapInSigners callback receives the signerInfos Constructed and replaces each child with a Signer, wiring up the parent pointers as it goes:

    public static function mapInSigners(Constructed $signers): void
{
ASN1::disableCacheInvalidation();
for ($i = 0; $i < count($signers); $i++) {
$signers[$i] = Signer::load((string) $signers[$i]->getEncoded());
$signers[$i]->signer->parent = $signers;
$signers[$i]->signer->depth = $signers->depth + 1;
$signers[$i]->signer->key = $i;
}
ASN1::enableCacheInvalidation();
}

A patch to phpseclib

For Signer objects to be re-encodable as part of the larger structure, ASN1::encodeDER() needs to recognize them. Currently, it has a hardcoded list of types it can handle, and Signer isn't on it. You'll need to patch File/ASN1.php:

#
#-----[ FIND ]------------------------------------------
# in the encode_der() method
#
if ($source instanceof X509 || $source instanceof CRL || $source instanceof Signer || $source instanceof Recipient || $source instanceof EncryptedKey) {
return $source->getEncoded();
}
#
#-----[ REPLACE WITH ]----------------------------------
#
if ($source instanceof X509 || $source instanceof CRL || $source instanceof Signer || $source instanceof Recipient || $source instanceof EncryptedKey || $source instanceof \Signer) {
return $source->getEncoded();
}

This is one of the rougher edges of the current API - see Open Issues.

compile() for Signer

One more piece. Try this:

$cms = SignedData::load(file_get_contents('signed.p7m'));
$signer = &$cms['content']['signerInfos'][0];
$signer['sid']['issuerAndSerialNumber']['issuer']['rdnSequence'][0][0]['value'] = 'US';
echo $cms['content']['signerInfos'][0]['sid']['issuerAndSerialNumber']['issuer']['rdnSequence'][0][0]['value'] . "\n";
$cms = SignedData::load($cms->getEncoded());
echo $cms['content']['signerInfos'][0]['sid']['issuerAndSerialNumber']['issuer']['rdnSequence'][0][0]['value'];

It throws an error. The reason is that Signer inherits the empty compile() method from the skeleton, and after the modification it has no way to refresh its cached encoding before the round trip. The fix is a Signer-flavored compile():

    private function compile(): void
{
if (!$this->signer instanceof Constructed || !$this->signer->hasEncoded()) {
$encoded = ASN1::encodeDER($this->signer, Maps\SignerInfo::MAP);
$temp = self::load($encoded);
$this->signer = $temp->signer;
}
}

Simpler than SignedData::compile() because there's no nested Constructed to deal with - just a single round trip through the encoder.

Open Issues

What's been built works, and the same patterns are used throughout phpseclib for X509, CRL, CSR, and the various CMS classes. But as you may have noticed walking through this example, the API for building on Constructed is more low-level than it should be. Specific rough spots:

  • File/ASN1.php has a hardcoded list of recognized wrapper types. Adding a new wrapper requires patching phpseclib itself, as we saw above. A registration mechanism — or duck-typing on getEncoded() - would let user code add wrappers without modifying the library.
  • The skeleton is boilerplate. Every wrapper around Constructed reimplements the same ArrayAccess, Iterator, and Countable delegations, plus the same pass-throughs for getEncoded(), hasEncoded(), toArray(), and __debugInfo(). None of that varies between wrapper classes, and a trait or base class could absorb it. compile() and load() would still need to be implemented per class - those are where the actual schema-specific logic lives - but everything else is mechanical.

This walkthrough is just scratching the surface - there's plenty more involved in a full SignedData implementation - but it should be enough to show both the shape of the work and where the friction lives.