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:
- 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.
- The current API for building on top of
Constructedis 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
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
content
version
phpseclib4\File\ASN1\Types\Integer
digestAlgorithms
0
algorithm
phpseclib4\File\ASN1\Types\OID
encapContentInfo
eContentType
phpseclib4\File\ASN1\Types\OID
eContent
certificates
0
certificate
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
3
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\PrintableString
3
0
type
phpseclib4\File\ASN1\Types\OID
value
phpseclib4\File\ASN1\Types\PrintableString
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
4
extnId
phpseclib4\File\ASN1\Types\OID
critical
phpseclib4\File\ASN1\Types\Boolean
extnValue
phpseclib4\File\ASN1\Types\OctetString
5
extnId
phpseclib4\File\ASN1\Types\OID
critical
phpseclib4\File\ASN1\Types\Boolean
extnValue
phpseclib4\File\ASN1\Types\OctetString
6
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
signerInfos
0
version
phpseclib4\File\ASN1\Types\Integer
sid
issuerAndSerialNumber
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
3
0
type
phpseclib4\File\ASN1\Types\OID
value
phpseclib4\File\ASN1\Types\PrintableString
serialNumber
phpseclib4\File\ASN1\Types\Integer
digestAlgorithm
algorithm
phpseclib4\File\ASN1\Types\OID
signedAttrs
0
type
phpseclib4\File\ASN1\Types\OID
value
0
phpseclib4\File\ASN1\Types\OID
1
type
phpseclib4\File\ASN1\Types\OID
value
0
phpseclib4\File\ASN1\Types\UTCTime
2
type
phpseclib4\File\ASN1\Types\OID
value
0
phpseclib4\File\ASN1\Types\OctetString
3
type
phpseclib4\File\ASN1\Types\OID
value
0
phpseclib4\File\ASN1\Element
signatureAlgorithm
algorithm
phpseclib4\File\ASN1\Types\OID
signature
phpseclib4\File\ASN1\Types\OctetString
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.phphas 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 ongetEncoded()- would let user code add wrappers without modifying the library.- The skeleton is boilerplate. Every wrapper around
Constructedreimplements the sameArrayAccess,Iterator, andCountabledelegations, plus the same pass-throughs forgetEncoded(),hasEncoded(),toArray(), and__debugInfo(). None of that varies between wrapper classes, and a trait or base class could absorb it.compile()andload()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.