Summary of
@awacloud/pdf's crypto choices and the security properties it does (or does not) guarantee.
Prerequisites — the package @awacloud/pdf (root entry
@awacloud/pdf, or the committed @awacloud/pdf/standalone/* build) and its
@awacloud/fw / @awacloud/fonts dependencies; any modern JavaScript runtime
(browser main thread or Worker, Bun, Node.js 18+). The cryptography goes through @awacloud/fw/crypto/*
(synchronous, no Web Crypto); the modules below are @awacloud/pdf/crypto/*
and @awacloud/pdf/sig/*.
Standard Security Handler (V=4 R=4 / V=5 R=5 / V=5 R=6)
crypto/standardV4.js implements the V=4 R=4 handler (AES-128-CBC or
RC4-128 crypt filters), crypto/standardV5.js the V=5 R=5 handler (AES-256,
Algorithm 2.A, no hardening loop) and crypto/standardV6.js the PDF 2.0
V=5 R=6 handler (AES-256 with the Algorithm 2.B hardening loop). read
does not apply them by itself: it refuses an encrypted file unless
allowEncrypted: true is passed, and the caller then decrypts through
these handlers.
AES-CBC without a MAC: malleability risk
The PDF format mandates plain AES-256-CBC for encrypted strings and
streams when /Method /AESV3 is used. Consequences:
- No authenticated integrity protection on an encrypted field: an attacker able to modify ciphertext bytes of an encrypted PDF string causes predictable changes in the decrypted plaintext (CBC bit-flipping). Until a MAC or AEAD is applied on top (e.g. a digital signature over the whole document), malleability is a property of the format itself.
- The TS 32003 — AES-GCM extension covers this by replacing CBC with
GCM (authenticated encryption). The
aesGcm.jsmodule is shipped for consumers who want to implement this out-of-strict-spec extension.
Password authentication
Algorithm 2.B (R=6) resists dictionary attacks correctly thanks to the
hardening loop (≥ 64 iterations of SHA-2 + AES-128, conditional
termination on the ciphertext). In standardV6.js:
- The AES-128 key-schedule scratch buffer is reused across rounds.
- A forced bail-out at 1024 rounds guards against a runaway loop
(
pdf/crypto/v6/hardening-runaway).
Signatures
sig/signature.js and sig/timestamp.js type PKCS#7/CMS signatures and
RFC 3161 tokens and execute real public-key verification through the
verifyPk primitive dispatcher (RSA-PSS, ECDSA, Ed25519) — verifyPk is
wired by construction, called from _verifyPkcs7Signature on every
verifySignature(...)/verifyAllSignatures(...) call. There is no
"structural-only, PK verify left to the consumer" mode: the digest is
recomputed from the /ByteRange-covered bytes, the signer certificate's
SPKI is extracted, and the matching fw primitive
(rsa.pssVerify/ecc.ecdsa…verify/ed25519.verify) is invoked directly.
Semantics of verifySignature(...)
| Field | Meaning |
|---|---|
verified |
true iff the RSA-PSS / ECDSA / Ed25519 public-key verification ran and succeeded. false in every other case (parse failure, digest mismatch, missing/unmatched signer cert, unsupported algorithm, or a failed public-key check). |
valid |
Historical alias of verified — kept in sync, same boolean. |
pkVerified |
true only once _verifyPkcs7Signature reaches and passes the verifyPk call; false on every earlier bail-out. |
computedDigest |
The digest recomputed over the /ByteRange ranges — useful for a caller that wants to re-run rsa.pssVerify / ecc…verify itself. |
errors |
Array of { code, message } records — the parse/verification step that failed, if any (e.g. pdf/sig/pkcs7-malformed, pdf/sig/signer-cert-not-found, or a pdf/sig/verify-pk/* code from verifyPk on primitive-level failure). |
RSA PKCS#1 v1.5 signatures are refused outright
(pdf/sig/rsa-pkcs1v15-deprecated, NIST SP 800-131A Rev.2) rather than
verified — use RSA-PSS.
Ceiling that remains real: certChain.validateChainOrder compares
issuer/subject strings only (no cryptographic chain, revocation, or trust
anchor check). Still open: a cryptographic chain-of-trust check over the
PKCS#7 SignerInfo certificates, and PAdES long-term validation (which
needs network I/O for revocation data).
valid/verified: true proves the PKCS#7 signature was cryptographically
valid over the signed bytes and the signer certificate was structurally
matched — it is not proof the certificate itself is trusted or
unrevoked.
Hardened ByteRange verification
auditByteRange(documentBytes, byteRange, opts) (from L3+) detects:
pdf/sig/byterange/self-overlap— the second range starts before the first one ends.pdf/sig/byterange/gap-start-mismatch/pdf/sig/byterange/gap-end-mismatch— the declared gap does not land exactly on the/Contentshex string.pdf/sig/byterange/cross-overlap— overlap with another/ByteRange(useful for multiple signatures / incremental updates).pdf/sig/byterange/incomplete-coverage— the second range does not reachdocumentBytes.length(unsigned tail).pdf/sig/byterange/non-zero-start— the first range does not start at 0.
Active actions (/Launch, /JavaScript, /SubmitForm)
The action/launch.js typer returns a record with:
{ kind: 'Launch', sandboxed: true,
securityWarning: 'launch actions are not executed by @awacloud/pdf', … }
The opt-in pdfSandbox linter (bundle pdf-full) consolidates every
active-content record into a unified audit — lintActions(records)
returns { issues, hasErrors, hasActiveContent } with error severity for
Launch / JavaScript / ImportData and warning for URI /
SubmitForm / Rendition + /JS.
The package never executes anything. These helpers exist so the host application can refuse or prompt before propagating active content.