Skip to content

feat(certificates): read the identity a Brazilian signer is known by - #247

Merged
lsnepomuceno merged 1 commit into
mainfrom
feat/icp-brasil-identity
Aug 12, 2026
Merged

feat(certificates): read the identity a Brazilian signer is known by#247
lsnepomuceno merged 1 commit into
mainfrom
feat/icp-brasil-identity

Conversation

@lsnepomuceno

Copy link
Copy Markdown
Owner

This package exists for Brazilian signing and could not answer the first question anyone asks of a signed document: who signed it.

In Brazil that answer is a CPF or a CNPJ. The package handed back this:

$signer->commonName;   // "JOAO DA SILVA:11144477735"

So every consumer wrote explode(':', $commonName). That breaks on a name containing a colon, and it is simply wrong for an e-CNPJ: its common name carries the company, while the CPF in the extension belongs to whoever answers for it.

Now

$signer = A1PdfSign::validate($path)->signers()[0];

$signer->icpBrasil?->cpf;                 // '11144477735'
$signer->icpBrasil?->cnpj;                // the company, for an e-CNPJ
$signer->icpBrasil?->formattedRegistry(); // '11.222.333/0001-81'
$signer->name();                          // 'JOAO DA SILVA', without the number

Why it was not read before

openssl_x509_parse() cannot do it. Every ICP-Brasil field is an otherName under 2.16.76.1.3, and PHP renders each one as othername:<unsupported>. The Asn1Reader that 0019 built for the CMS goes where it cannot.

Three ways to read the layout wrong, from the specification itself

Verified against the Receita Federal's certificate layout, §2.2.5 for e-CPF and §3.2.5 for e-CNPJ, not from memory:

  • There are no separators. A field read one character short reads the next one wrong rather than failing.
  • The last field of a layout may run short. The six positions for the RG's issuing authority "refer to the maximum size, and only the positions needed are used". It is read as the remainder, and the width check allows exactly that one field to be short.
  • "Unavailable" is written as zeros. Eleven zeros and an absent field are the same fact, so callers get null rather than a string they would have to know to test for.

The structural check

A1PdfSign::icpBrasil($pfxPath, $password) says which field is wrong, from the file, before anything is signed.

Required fields three otherName entries for e-CPF, four for e-CNPJ
Widths the layout's, with the last allowed short
Alphabet "only A to Z and 0 to 9"
Check digits modulus eleven, both numbers
Birth date a real date in ddmmyyyy
RG an issuer named for a number that is absent, which the specification calls out
The CPF twice common name against extension, which nothing in the format makes agree

⚠️ conforms() is not isTrusted()

A self-signed certificate can be built to satisfy every rule above, and this PR builds one: DebugCertificate::icpBrasil() is what the tests run against. It chains to nothing and no trust store will accept it.

The value is upstream of trust rather than instead of it. A certificate that fails here will be read wrong by everything downstream, and finding that out from the bytes beats finding it out from a rejected filing. Stated in the class docblock, the report docblock, the contract and 0029, because it is the one thing about this feature that is dangerous to get wrong.

Surface

  • Contracts\A1PdfSign gained icpBrasil(): a break for implementers, which Roave reports.
  • Data\Signer gained $icpBrasil and name(), appended with defaults.
  • Pkcs7Reader::signers() goes through the PEM rather than through a parse, because the identity is only in the bytes.
  • Support\NationalRegistry is bespoke by necessity: neither Laravel nor any dependency here validates a CPF.

It says a number is well formed, never that it exists. Whether the Receita Federal issued it is a question only they answer, and asking would mean a network call from validation, which nothing here does.

composer check green: 473 passing, 30 of them new, PHPStan level max, Pint clean.

This package exists for Brazilian signing and could not answer the first
question anyone asks of a signed document: who signed it. In Brazil that
is a CPF or a CNPJ, and the package handed back "JOAO DA SILVA:11144477735"
with the number glued to the name.

So every consumer wrote explode(':', $commonName), which breaks on a name
containing a colon and is simply wrong for an e-CNPJ: its common name
carries the company while the CPF in the extension belongs to whoever
answers for it.

The structured identity lives in subjectAlternativeName, as otherName
entries under 2.16.76.1.3, and openssl_x509_parse() renders every one of
them as `othername:<unsupported>`. The Asn1Reader 0019 built for the CMS
can go where it cannot.

Three details of the layout are each a way to read a field wrong, and are
handled rather than assumed: there are no separators, so a field read one
character short reads the next one wrong; the last field of a layout may
run short, which the specification states about the RG's issuing
authority; and "unavailable" is written as zeros, which comes back as null
because zeros and absence are the same fact.

IcpBrasilValidator checks what the specification states about the bytes:
required fields, widths, the A-Z 0-9 alphabet, modulus eleven on both
numbers, a real birth date, an issuer named for an RG that is absent, and
the two places a CPF appears agreeing.

conforms() is not isTrusted(), and keeping them apart is the whole risk
here. DebugCertificate::icpBrasil() builds a self-signed certificate that
satisfies every rule, which is what the tests run against.
@lsnepomuceno lsnepomuceno added enhancement New feature or request php Pull requests that update php code labels Aug 12, 2026
@lsnepomuceno lsnepomuceno self-assigned this Aug 12, 2026
@lsnepomuceno
lsnepomuceno merged commit 36f03ee into main Aug 12, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request php Pull requests that update php code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant