Verifying signatures
$report = new Signet()->validate('/path/contract-signed.pdf');
$report->isValid(); // every signature verifies against the bytes it covers
$report->isSigned(); // the document carries at least one signature
$report->count(); // how many
$report->signers(); // list<Data\Signer>
$report->latest(); // ?Data\SignatureDetailsisValid() means the CMS actually verifies. Not that a subject line could be parsed, not that a /ByteRange was present, not that a reader showed a green tick. The signature is checked against the bytes it covers, and the answer is that check.
The document does not have to be a file
validate(), signatureFields() and extendArchive() take a path or a Contracts\PdfSource, the same way signing does (0102):
use LSNepomuceno\Signet\Io\StreamSource;
use LSNepomuceno\Signet\Io\StringSource;
$signet->validate(new StringSource($bytes, 'contract.pdf')); // a queue payload
$signet->validate(new StreamSource($handle, 'contract.pdf')); // object storageA document in a queue message, in object storage behind your own driver, or just produced in memory is checked where it is. Nothing is written to disk on that path, which matters to a worker with a read-only filesystem and to anyone who would rather not put a signed document somewhere nobody asked to store it.
Nothing is fetched
Validation makes no network request and cannot be made to. Revocation is evaluated from the material the document itself carries, and that material is verified against its issuer before it is believed (0024).
This is a decision rather than a gap: a validator that reaches the network gives different answers on different days, from different machines, and inside networks that refuse it.
What decides that a signature matches
Contracts\SignatureVerifier, and there are two implementations.
The default runs the openssl binary, which is the conservative implementation of a security decision: OpenSSL's own CMS code has been read by more people than anything written here ever will be.
Validation\NativeSignatureVerifier answers the same questions through ext-openssl, with no process at all, which is what makes validation possible on a host where proc_open is disabled:
new Signet(verifier: new NativeSignatureVerifier())->validate($path);It checks the signature over the signed attributes, the message-digest attribute against the covered bytes, the content-type, and the ESS signing-certificate-v2 attribute against the certificate that verified. Both implementations are put to every sample, to a foreign document and to three tamper cases, and a disagreement fails this package's build (0114).
It is opt in rather than a fallback, because an environment change should not silently change which code decides whether a signature is valid.
Trust is unaffected either way: Validation\TrustVerifier asks openssl_x509_checkpurpose(), which is the extension rather than the binary. It does write the roots to a temporary file, because that function reads PEM from paths, so a writable temporary directory is needed for a trust store and for nothing else in validation.
Per signature
$signature = $report->latest();
$signature?->verified; // bool, this one signature's CMS
$signature?->profile; // ?Enums\SignatureProfile, what it satisfies
$signature?->coversWholeDocument; // bool
$signature?->signedAt; // ?int, the signer's own clock
$signature?->attestedAt(); // ?int, the authority's time, or null
$signature?->hasTimestamp(); // bool
$signature?->isTrusted; // ?bool, only when a store was given
$signature?->chain; // list<Data\Signer>, leaf first
$signature?->chainReachesRoot; // bool
$signature?->revocation; // Enums\RevocationStatus
$signature?->messageDigest; // ?string, lowercase hex
$signature?->digestAlgorithm; // ?string, 'sha256' and friends
$signature?->byteRangeSound; // bool
$signature?->signaturePolicy; // ?Data\SignaturePolicy, or nullsignedAt comes from /M in the signature dictionary. It is inside the range the signature covers, so altering it breaks the signature, but it is still the signer's own clock. Only an RFC 3161 timestamp, which pades-b-t and above carry, makes the time attributable to a third party, and that is what attestedAt() returns.
An archive timestamp is the token, so for an entry with isTimestamp true attestedAt() is its own genTime: nothing stamps a DocTimeStamp, and timestampVerified is null for one by construction. That is what signet extend --if-due reads to decide whether an archive is old enough to renew.
The declared policy
signaturePolicy is the signature-policy-identifier signed attribute of RFC 5126 §5.8.1: an OID naming a policy document, the digest of that document, and optionally a URI where it can be fetched.
$policy = $signature?->signaturePolicy;
$policy?->oid; // '2.16.76.1.7.1.1.1', dotted
$policy?->digestAlgorithm; // 'sha256'
$policy?->digest; // lowercase hex, as the signer computed it
$policy?->uri; // ?string, from the sp-uri qualifierIt matters in Brazil, where a verifier looks for it before calling a signature ICP-Brasil conformant: a signature carrying none is cryptographically fine and still reported as conformant to nothing.
This is what the document says, not a verdict. Nothing checks that the policy was satisfied, or that the OID names a policy that exists. Doing either means holding the published policy artefacts, and nothing here fetches the URI: the network stays behind the injected transport.
Null is every signature this package produces today. Declaring a policy means adding a signed attribute, which has to be there before the attributes are signed, and the CMS library underneath exposes no way to contribute one. That half is #56.
Findings
isValid() is one boolean over one question. findings() is everything else the validator established, as values rather than as prose:
use LSNepomuceno\Signet\Enums\ValidationFinding;
$report->findings(); // list<ValidationFinding>, unioned across the document
$signature->findings(); // per signature
$signature->has(ValidationFinding::CertificateRevoked);| Case | Raised when |
|---|---|
CmsDoesNotVerify | the embedded CMS does not verify against the bytes it covers |
DoesNotCoverWholeDocument | bytes were appended after this signature |
ChainDoesNotReachRoot | no chain to a self-issued certificate could be built |
NotTrusted | a trust store was given and the chain does not end in it |
CertificateRevoked | the document's own OCSP or CRL says so |
RevocationUnknown | nothing the document carries answers the question |
SignerOutsideValidityWindow | the certificate was outside its window when it signed |
TimestampDoesNotVerify | an RFC 3161 token is present and fails |
NoSigningTime | the CMS carries no signing-time attribute |
ByteRangeNotSound | the /ByteRange does not describe a signature's own /Contents |
CertificationViolated | a revision appended after the certification did something its level forbids |
LockedFieldChanged | a revision rewrote a form field an earlier signature locked |
WeakDigestAlgorithm | the signature was computed under MD5 or SHA-1 |
WeakSignatureKey | RSA or DSA below 2048 bits, an elliptic curve below 224 |
WeakTimestampDigest | the RFC 3161 token carries the same weakness |
KeyUsageDoesNotPermitSigning | the certificate's own extensions say it is not for signing documents |
Only CmsDoesNotVerify decides validity, and decidesValidity() on the enum says so. The other fifteen are facts for your policy, which is why the enum carries no severity: how much NotTrusted matters is not this package's call.
Certification and locks are evaluated, not just reported
isCertified() says a /DocMDP is there and changesAfter says what every later revision touched. CertificationViolated joins them: a document certified as no-changes and then modified by something that is not this package verifies perfectly, because the altered bytes are outside the earlier signature's /ByteRange, and what the certification said is that they should not be there at all. That is the attack /DocMDP exists for (0012).
LockedFieldChanged is the same shape one level down: /Lock on a signature field names the fields that stop being fillable, and a later revision that rewrote one of them is now reported (0021).
Two boundaries worth knowing:
- an archive timestamp is never a violation, at any level including
no-changes. A DocTimeStamp adds no content, it attests that bytes already there existed, and ETSI EN 319 142-1 permits one over a certified document.extendArchive()still refuses to write one atno-changes, which is the conservative side of a conflict between two standards; - a replaced page object is not reported. Attaching a signature widget replaces the page, so a revision that rewrote a page's content cannot be told apart from an ordinary second signature by an analysis that reads objects rather than the object graph (0110). What is caught is what that document's own fixture does elsewhere: resizing a page and nothing else signing.
Weak is not invalid
A SHA-1 signature verifies. So does one made with a 1024-bit key, and so does one made by a TLS server certificate. Reporting any of them as invalid would be a lie of a different kind, so each is a finding and isValid() stays true.
The thresholds are policy and they age, so they live in one place, Support\CryptographicStrength, naming the standards they came from (SOG-IS, NIST SP 800-57, NIST SP 800-131A, ETSI TS 119 312) and the date they were read. They are deliberately set at "broken or too small to argue about" rather than at what anyone should sign with today: a finding raised on every 2048-bit RSA signature in Brazil would be noise, and noise is how a real finding gets ignored.
KeyUsageDoesNotPermitSigning is read from the certificate and never from what it was used for. A certificate declaring neither keyUsage nor extendedKeyUsage raises nothing, since RFC 5280 §4.2.1.3 reads an absent keyUsage as unconstrained, and an extendedKeyUsage naming a purpose this package does not model raises nothing either: unknown means unjudged.
$signature->signer()?->keyAlgorithm; // 'RSA', 'EC', 'DSA'
$signature->signer()?->keyBits; // 2048
$signature->signer()?->keyUsage; // ['Digital Signature', 'Non Repudiation']
$signature->signer()?->extendedKeyUsage;
$signature->timestampDigestAlgorithm; // what the authority stamped withAn empty list is not a recommendation to accept. It means nothing was found to say.
What changed after a signature
$signature->changesAfter; // list<Data\RevisionDiff>
$signature->onlyAddedSignatures(); // boolcoversWholeDocument tells you bytes were appended. onlyAddedSignatures() tells you what they did: whether everything appended afterwards was itself a signature, or whether a revision added an annotation, a page, a form field or an action.
True is not a verdict of safe
A counter-signer produces the same shape, and so does anyone able to append a signature. It rules out content changes, not the right to sign (0110).
Enums\RevisionChange is the vocabulary: SignatureAdded, TimestampAdded, SecurityStoreWritten, Annotations, FormFields, Pages, Catalog, Actions, Other.
How long it stays verifiable
$report->verifiableUntil(); // ?int, for the document
$signature->verifiableUntil(); // ?int, for one signatureThis is the chain's earliest expiry, not the leaf's: an expired intermediate breaks the path while the leaf is still inside its own window. At document level an archive timestamp renews the horizon rather than the signatures deciding it.
null from either means the question cannot be answered, not that the answer is "never".
Is this document usable offline
$report->hasLongTermMaterial(); // bool, material present for every signature
$report->isSelfContained(); // bool, nothing detectable missing
$report->missingValidationMaterial(); // list<string>, what is missing, and for which signature
$report->securityStore; // ?Data\SecurityStoremissingValidationMaterial() asks the sufficiency question that hasLongTermMaterial() does not: a store can name every signature and still carry no revocation material at all.
An empty list means nothing detectable is missing, not that the document is proven self-contained. Proving that needs the store's objects decoded (0109).
Timestamps are classified separately
$report->timestamps(); // DocTimeStamps, not signaturesA /DocTimeStamp carries no signer, so it cannot make a document invalid and is excluded from isValid(). It is still included in findings(), because a timestamp that fails to verify is exactly what a reader needs told.
Certification
$report->isCertified();
$report->certification; // ?Enums\CertificationLevel
$report->acceptsFurtherSignatures();Covered in full in Certification and locks.
From the command line
vendor/bin/signet verify contract-signed.pdf
vendor/bin/signet verify contract-signed.pdf --jsonThe verdict is in the exit status, so a build can gate on it: 0 every signature verifies, 1 one does not, 2 the document could not be read. See Command line.