diff --git a/.github/instructions/testing.instructions.md b/.github/instructions/testing.instructions.md index 2dd4825..d909f27 100644 --- a/.github/instructions/testing.instructions.md +++ b/.github/instructions/testing.instructions.md @@ -29,5 +29,7 @@ applyTo: "tests/**" ## Coverage targets -- Statements ≥ 90% · Branches ≥ 80% · Functions ≥ 85% · Lines ≥ 90%. -- `src/index.ts` excluded (entry point, covered by smoke test). +- Enforced thresholds live in `vitest.config.ts` (single source of truth): + Statements ≥ 79% · Branches ≥ 68% · Functions ≥ 83% · Lines ≥ 79%. +- `src/index.ts` and the network-transport / CMS-engine modules are excluded + (see the commented exclude block in `vitest.config.ts`). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac3e07b..a9b62ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,7 @@ jobs: strategy: fail-fast: false matrix: - node-version: [20, 22] + node-version: [22, 24] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 714ee48..e55ec3a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -30,7 +30,12 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '>=20' + # A range resolves to the NEWEST available Node (currently 24.x), + # whose bundled npm >= 11.5.1 is required by npm Trusted Publishing + # (token-less OIDC). A '22.x' pin would ship npm 10.9 and break the + # publish. The floor mirrors the engines >= 22 support policy and + # the sibling pdfnative workflow. + node-version: '>=22.14.0' registry-url: https://registry.npmjs.org cache: npm @@ -58,6 +63,62 @@ jobs: - name: Binary smoke test run: node dist/cli.cjs --help + # PDF/A gate: the release must never publish samples/claims that the + # reference validator rejects. Same pinned veraPDF (URL + SHA-256) as + # .github/workflows/verapdf.yml — BLOCKING, no continue-on-error. + - name: Setup Java (Temurin 17 LTS) + uses: actions/setup-java@d7793b545071e98d581d3bf084a51c3213318a07 # v4.9.0 + with: + distribution: temurin + java-version: '17' + + - name: Install veraPDF CLI + env: + VERAPDF_INSTALL_URL: 'https://software.verapdf.org/rel/1.30/verapdf-greenfield-1.30.2-installer.zip' + # SHA-256 of the installer zip. The step fails on mismatch: the + # archive is executed with `java -jar`, so a swapped artefact on + # the download host would otherwise run arbitrary code on the + # runner. Recompute when bumping the URL: + # curl -fsSL | sha256sum + VERAPDF_INSTALL_SHA256: '6cc6341cb1af644044054b81f00a6590a7918abb18f762243de115258bcad838' + run: | + set -euo pipefail + mkdir -p "$HOME/verapdf-installer" + curl -fsSL -o "$HOME/verapdf-installer/installer.zip" "${VERAPDF_INSTALL_URL}" + echo "${VERAPDF_INSTALL_SHA256} $HOME/verapdf-installer/installer.zip" | sha256sum --check --strict + unzip -q "$HOME/verapdf-installer/installer.zip" -d "$HOME/verapdf-installer" + INSTALLER_JAR=$(find "$HOME/verapdf-installer" -name 'verapdf-izpack-installer-*.jar' | head -n1) + if [ -z "${INSTALLER_JAR}" ]; then + echo "veraPDF installer jar not found"; exit 1 + fi + mkdir -p "$HOME/verapdf" + # Auto-install via izpack XML descriptor (headless). + cat > "$HOME/verapdf/auto-install.xml" < + + + $HOME/verapdf + + + + + XML + java -jar "${INSTALLER_JAR}" "$HOME/verapdf/auto-install.xml" + echo "VERAPDF_HOME=$HOME/verapdf" >> "$GITHUB_ENV" + echo "$HOME/verapdf" >> "$GITHUB_PATH" + + - name: Generate PDF/A corpus + # dist/ is already built by the Build step above. + run: npm run corpus:pdfa + + - name: Validate PDF/A corpus (blocking) + env: + # Fail-closed: a missing / broken veraPDF or Java is an INFRA + # failure (exit 3), never a silent skip. + VERAPDF_REQUIRED: '1' + VERAPDF_REPORT_DIR: test-output/pdfa/reports + run: node scripts/validate-pdfa.mjs + - name: Generate SBOM (CycloneDX) # Software Bill of Materials for supply-chain transparency. Uses the # CycloneDX generator via npx (build-time only — adds ZERO runtime diff --git a/.github/workflows/verapdf.yml b/.github/workflows/verapdf.yml new file mode 100644 index 0000000..ce407bf --- /dev/null +++ b/.github/workflows/verapdf.yml @@ -0,0 +1,200 @@ +name: PDF/A Validation (veraPDF) + +# Runs the veraPDF reference validator against a corpus of PDF/A-claiming +# documents produced by the CLI itself (scripts/generate-pdfa-corpus.mjs → +# test-output/pdfa/), covering a representative sample of the PDF/A-relevant +# features the CLI exposes. Each file is validated against the profile it +# claims in XMP and compared with the manifest's `expectCompliant` flag; the +# corpus includes negative canaries that veraPDF MUST reject (an unexpected +# pass — XPASS — is fatal), so a validator that accepts everything fails the +# run instead of turning it green. +# +# Status: BLOCKING in pdfnative-cli as of v1.4.0 (precedent: pdfnative made +# its gate blocking in v1.1.0+). The validate step has no `continue-on-error`, +# so any deviation from the manifest expectations fails the workflow. +# Exit codes of scripts/validate-pdfa.mjs: +# 0 all expectations met · 1 conformance FAIL / XPASS · 2 no corpus · +# 3 INFRA (veraPDF unusable or produced no report — not a verdict). +# +# NOTE on `paths:` filters vs. required checks: this workflow only triggers +# when the listed paths change. GitHub reports a path-filtered workflow that +# did not run as "Expected — Waiting for status" on a PR, which BLOCKS merging +# forever if the check is marked "required" in branch protection. Before +# making this job a required check, either drop the `paths:` filters (run it +# on every PR) or add a companion workflow with `paths-ignore` mirroring this +# list that reports a passing status of the same name. +# +# veraPDF is an external CI tool (a Java CLI installed on the runner). It is +# not a dependency of pdfnative-cli: the package keeps its zero-runtime- +# dependency policy (`pdfnative` is the only runtime dependency), and the +# validator is never bundled or linked. + +on: + push: + branches: [main, master] + paths: + - 'src/**' + - 'samples/**' + - 'scripts/**' + - 'package.json' + - 'package-lock.json' + - 'tsup.config.ts' + - 'tsconfig.json' + - '.github/workflows/verapdf.yml' + pull_request: + branches: [main, master] + paths: + - 'src/**' + - 'samples/**' + - 'scripts/**' + - 'package.json' + - 'package-lock.json' + - 'tsup.config.ts' + - 'tsconfig.json' + - '.github/workflows/verapdf.yml' + # Manual runs against any branch from the Actions UI. + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: verapdf-${{ github.ref }} + cancel-in-progress: true + +jobs: + verapdf: + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + # Pinned veraPDF release (greenfield) so CI and the documented local + # setup validate against the same validator version — a floating + # "latest" URL can silently change the rule set between two runs of + # the same commit. Bump deliberately, with the release index: + # https://software.verapdf.org/rel/ + VERAPDF_INSTALL_URL: 'https://software.verapdf.org/rel/1.30/verapdf-greenfield-1.30.2-installer.zip' + # SHA-256 of the installer zip above. The job fails on mismatch: the + # archive is executed with `java -jar`, so a swapped artefact on the + # download host would otherwise run arbitrary code on the runner. + # Recompute when bumping the URL: + # curl -fsSL | sha256sum + VERAPDF_INSTALL_SHA256: '6cc6341cb1af644044054b81f00a6590a7918abb18f762243de115258bcad838' + # Fail-closed: a missing / broken veraPDF or Java is an INFRA failure + # (exit 3), never a silent skip that would leave the step green. + VERAPDF_REQUIRED: '1' + VERAPDF_REPORT_DIR: test-output/pdfa/reports + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 + cache: npm + + - name: Setup Java (Temurin 17 LTS) + uses: actions/setup-java@d7793b545071e98d581d3bf084a51c3213318a07 # v4.9.0 + with: + distribution: temurin + java-version: '17' + + - name: Install veraPDF CLI + run: | + set -euo pipefail + mkdir -p "$HOME/verapdf-installer" + curl -fsSL -o "$HOME/verapdf-installer/installer.zip" "${VERAPDF_INSTALL_URL}" + echo "${VERAPDF_INSTALL_SHA256} $HOME/verapdf-installer/installer.zip" | sha256sum --check --strict + unzip -q "$HOME/verapdf-installer/installer.zip" -d "$HOME/verapdf-installer" + INSTALLER_JAR=$(find "$HOME/verapdf-installer" -name 'verapdf-izpack-installer-*.jar' | head -n1) + if [ -z "${INSTALLER_JAR}" ]; then + echo "veraPDF installer jar not found"; exit 1 + fi + mkdir -p "$HOME/verapdf" + # Auto-install via izpack XML descriptor (headless). + cat > "$HOME/verapdf/auto-install.xml" < + + + $HOME/verapdf + + + + + XML + java -jar "${INSTALLER_JAR}" "$HOME/verapdf/auto-install.xml" + echo "VERAPDF_HOME=$HOME/verapdf" >> "$GITHUB_ENV" + echo "$HOME/verapdf" >> "$GITHUB_PATH" + + - name: Verify veraPDF + run: | + verapdf --version + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Generate PDF/A corpus + run: npm run corpus:pdfa + + # BLOCKING (see header). The exit code drives the step outcome — the + # step fails naturally on a non-zero exit — and the summary step below + # surfaces the verdict: + # 0 all expectations met · 1 conformance FAIL / XPASS · 2 no corpus · + # 3 INFRA (veraPDF unusable or produced no report — not a verdict). + - name: Validate PDF/A corpus (blocking) + id: validate + run: | + set +e + node scripts/validate-pdfa.mjs 2> >(tee verapdf-stderr.txt >&2) | tee verapdf-report.txt + code=${PIPESTATUS[0]} + echo "exit_code=${code}" >> "$GITHUB_OUTPUT" + exit "${code}" + + # Raw per-file veraPDF XML (+ stderr when non-empty) lives under + # test-output/pdfa/reports/ — upload it so a FAIL / INFRA line can be + # diagnosed without re-running the job. + - name: Upload veraPDF report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: verapdf-report + path: | + verapdf-report.txt + verapdf-stderr.txt + test-output/pdfa/manifest.json + test-output/pdfa/reports/ + if-no-files-found: warn + retention-days: 14 + + - name: Job summary + if: always() + run: | + code='${{ steps.validate.outputs.exit_code }}' + case "${code}" in + 0) verdict='all expectations met' ;; + 1) verdict='CONFORMANCE failure (FAIL or XPASS — see report)' ;; + 2) verdict='no corpus generated' ;; + 3) verdict='INFRA failure — veraPDF unusable or produced no report (NOT a conformance verdict)' ;; + *) verdict="unknown (exit ${code:-n/a})" ;; + esac + { + echo "## PDF/A validation (veraPDF, blocking)" + echo + echo "Step outcome: ${{ steps.validate.outcome }} · exit ${code:-n/a} · ${verdict}" + echo + echo '```' + cat verapdf-report.txt 2>/dev/null || echo "(no report produced)" + echo '```' + if [ -s verapdf-stderr.txt ]; then + echo + echo '
validator stderr' + echo + echo '```' + cat verapdf-stderr.txt + echo '```' + echo '
' + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index fa8c61c..50a7263 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ test-output/ # Sample output (generated PDFs — not committed) samples/output/ +samples/batch/manifest/out/ output.pdf # Environment files — never commit secrets diff --git a/AGENTS.md b/AGENTS.md index 50340f3..ff55da2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,9 +14,11 @@ a process, pass flags, read stdout/stderr, branch on the exit code). ## 1. The process contract +**Prerequisite:** Node.js ≥ 22 (check with `pdfnative doctor`). + | Channel | Carries | |---------|---------| -| **stdout** | The primary artifact: a PDF (`render`, `sign`, `merge`, `extract`, `annotate`, `fill`, `encrypt`, `decrypt`), extracted text (`extract-text`), a JSON report (`inspect`, `verify`, `batch --format json`, `govern verify-issue --json`, `doctor --format json`, `fill --export`), a JSON Schema (`schema`), the governance protocol/policy (`govern rules`/`policy`), or a completion script (`completion`). `split` writes its parts to `--output-dir`. | +| **stdout** | The primary artifact: a PDF (`render`, `sign`, `merge`, `extract`, `annotate`, `fill`, `encrypt`, `decrypt`, `metadata`, `ltv embed`/`ltv add`, `doc-timestamp`), extracted text (`extract-text`), a JSON report (`inspect`, `verify`, `compare --format json`, `batch --format json`, `govern verify-issue --json`, `doctor --format json`, `fill --export`, `ltv collect`), a JSON Schema (`schema`), the governance protocol/policy (`govern rules`/`policy`), or a completion script (`completion`). `split` writes its parts to `--output-dir`. | | **stderr** | All diagnostics: progress, warnings, and the agent JSON envelopes below. | | **exit code** | `0` success · `1` runtime error · `2` usage error. Unchanged in every mode. | @@ -38,13 +40,18 @@ default to JSON on stdout). ``` **On success**, the write commands — `render` / `sign` / `merge` / `split` / `extract` / -`annotate` / `fill` / `encrypt` / `decrypt` / `batch` — write a status line to stderr -(schema: `pdfnative schema status`): +`annotate` / `fill` / `encrypt` / `decrypt` / `metadata` / `ltv` / `doc-timestamp` / +`batch` — write a status line to stderr (schema: `pdfnative schema status`): ```json { "ok": true, "command": "render", "variant": "document", "dryRun": false, "output": "out.pdf", "bytes": 12345 } ``` +Two additive fields can appear in the success envelope: a timestamped signature +(`sign --timestamp`) adds `timestamp: { url, digest }`, and `render` adds a +`diagnostics: [{ code, severity, message }]` array when non-strict PDF/A conformance +diagnostics were found. Both are pinned by the `status` schema. + `inspect`, `verify`, and `batch` put their result document on **stdout** as JSON; `--json` only adds the failure envelope on stderr and (for `batch`) forces the JSON summary. @@ -61,10 +68,11 @@ Branch on `error.code`, never on the human message: | `E_IO` | Filesystem or stream I/O failure | 1 | | `E_SIGN` | Signing failed (message is always generic — no key material) | 1 | | `E_VERIFY_FAILED` | `verify --strict` found an invalid signature | 1 | -| `E_CHECK_FAILED` | `inspect --check` assertion failed | 1 | +| `E_CHECK_FAILED` | `inspect --check` assertion failed, `compare` found differences, or `render --strict` hit a PDF/A diagnostic | 1 | | `E_POLICY` | `govern verify-issue` found a governance violation | 1 | | `E_UNSUPPORTED` | Reserved / not-yet-available capability | 2 | -| `E_PASSWORD` | Encrypted PDF: password missing or incorrect (`extract-text` / `fill` / `encrypt` / `decrypt` / `inspect` / page-tree) | 1 | +| `E_PASSWORD` | Encrypted PDF: password missing or incorrect (`extract-text` / `fill` / `encrypt` / `decrypt` / `inspect` / `annotate` / `metadata` / page-tree) | 1 | +| `E_NETWORK` | Opt-in network operation failed (TSA / OCSP / CRL fetch — `sign --timestamp`, `ltv collect`/`add`, `doc-timestamp`) | 1 | | `E_RUNTIME` | Catch-all runtime error | 1 | --- @@ -97,7 +105,7 @@ omitted (so a conditionally-absent field never crashes the run). Precedence: ```bash # Smallest possible "is this PDF signed and valid?" probe: pdfnative verify --input doc.pdf --json --summary # → {"valid":false,"signatures":0,"invalid":0} -pdfnative verify --input doc.pdf --json --fields valid # → {"valid":false} +pdfnative verify --input doc.pdf --json --fields allValid # → {"allValid":false} pdfnative inspect --input doc.pdf --json --fields pageCount,signatures pdfnative batch --input-dir in --output-dir out --json --summary ``` @@ -109,11 +117,13 @@ The compact shapes are schema-pinned — validate them with ## 4. Validate first — `--dry-run` -`render`, `sign`, `batch`, `merge`, `split`, `extract`, `annotate`, `fill`, `encrypt`, and `decrypt` accept `--dry-run`: +`render`, `sign`, `batch`, `merge`, `split`, `extract`, `annotate`, `fill`, `encrypt`, +`decrypt`, `metadata`, `ltv`, and `doc-timestamp` accept `--dry-run`: inputs are fully validated (JSON parsed, document/table shape checked, layout assembled, signing credentials loaded and the PDF prepared, page ranges and annotation specs -bounds-checked) but **no output is produced or written**. Combine with `--json` for a -`{ "ok": true, "dryRun": true, … }` envelope. +bounds-checked) but **no output is produced or written**. `--dry-run` **never performs +network I/O**, even when a network flag (`--timestamp`, `--url`, `--online`) is present. +Combine with `--json` for a `{ "ok": true, "dryRun": true, … }` envelope. ```bash pdfnative render --input doc.json --dry-run --json @@ -136,8 +146,12 @@ pdfnative schema annotate # annotation-spec accepted by `annotate --anno pdfnative schema extract-text # output of `extract-text --format json` pdfnative schema fill # values map accepted by `fill --data` pdfnative schema form-export # output of `fill --export` +pdfnative schema metadata # JSON accepted by `metadata --from-json` (v1.4.0) pdfnative schema doctor # output of `doctor --format json` pdfnative schema govern-verify # output of `govern verify-issue --json` +pdfnative schema ltv-data # replayable JSON from `ltv collect` / input of `ltv embed` (v1.4.0) +pdfnative schema compare # output of `compare --format json` (v1.4.0) +pdfnative schema batch-manifest # pipeline file accepted by `batch --manifest` (v1.4.0) pdfnative schema status # the --json success envelope (write commands) pdfnative schema manifest # capability manifest: commands, flags, error codes (DATA, not a schema) ``` @@ -195,12 +209,72 @@ For `verify`/`inspect`, read the JSON result on stdout and use `--strict` / `--check` to turn findings into exit codes for unattended gating. Add `--summary` (or `--fields`) to keep that stdout JSON token-cheap — see §3. +**PDF/A changes → veraPDF gate.** An agent working **on this repository** must run +`npm run validate:pdfa` for any change touching PDF/A behaviour (render, fonts, +metadata/XMP, signing over claiming files, the `samples/render/pdfa/` inputs): it +builds the CLI, generates a 12-file corpus (including negative canaries veraPDF must +reject) and validates every file against the profile it claims. Mind the skip +semantics — **without veraPDF installed the script exits 0 as a SKIP, not a pass**; +set `VERAPDF_REQUIRED=1` to fail closed. The same gate runs blocking in CI and +pre-publish. For rendering, the conformance recipe is +`--tagged pdfa --font latin --lang latin` (ISO 19005 requires embedded fonts). + +**Assert with `compare` (v1.4.0).** `compare a.pdf b.pdf` is a ready-made CI/agent +assertion: identical documents exit `0`; any text or structure difference exits `1` +with `E_CHECK_FAILED` (the diff report lands on stdout first — add `--format json` +and, e.g., `--tolerance 1 --ignore-whitespace` to absorb benign drift). Use it to +gate "did my edit change only what I intended?" without parsing anything. + +**Orchestrate with `batch --manifest` (v1.4.0).** Instead of shelling out N times, +declare the whole pipeline once and run it fail-fast in a single process: + +```json +{ "version": 1, "tasks": [ + { "id": "r", "command": "render", "flags": { "input": "doc.json", "output": "doc.pdf" } }, + { "id": "s", "command": "sign", "flags": { "input": "@r", "output": "signed.pdf" } }, + { "id": "v", "command": "verify", "flags": { "input": "@s", "strict": true } } +] } +``` + +`"@"` references the output of an earlier task; relative paths resolve against +the manifest's directory; 14 commands are whitelisted — `render`, `sign`, `verify`, +`inspect`, `merge`, `split`, `extract`, `extract-text`, `fill`, `encrypt`, `decrypt`, +`annotate`, `metadata`, `doc-timestamp` (`ltv` and `compare` need positional arguments +and are not yet manifest-callable; never `govern` / `schema` / `completion` / `doctor` / +`batch`). Validate the file with `schema batch-manifest`, pre-flight with `--dry-run`, +and remember: any network flag inside the manifest additionally requires +`--allow-network` on the command line. A manifest has the filesystem access of the user +who invokes `batch` — the same trust level as command-line flags; only network access is +additionally gated behind `--allow-network`. Manifests are size-capped (50 MB) and +bounded to 1 000 tasks, and path values undergo the same anti-traversal check as direct +CLI flags. + +### The PAdES ladder (long-term signatures) + +```bash +pdfnative sign --input doc.pdf --output bt.pdf \ + --profile pades --timestamp https://tsa.example/tsr # B-T (network: --timestamp) +pdfnative ltv add --input bt.pdf --output blt.pdf --online # B-LT (network: --online) +pdfnative doc-timestamp --input blt.pdf --output blta.pdf \ + --url https://tsa.example/tsr # B-LTA (network: --url) +pdfnative ltv add --input blta.pdf --output final.pdf --online +pdfnative verify --input final.pdf --strict # offline gate +``` + +Air-gapped variant: `ltv collect --online` on a connected machine emits a replayable +JSON (`schema ltv-data`); `ltv embed --data ltv.json` applies it fully offline. + --- ## 8. Safety notes for unattended use -- **Offline by default.** Only `verify --revocation online` makes network requests, - and only through an SSRF guard. Nothing else touches the network — including `govern`. +- **Offline by default.** The ONLY network opt-ins are `verify --revocation online`, + `sign --timestamp `, `ltv collect|add --online`, `doc-timestamp --url `, and + `batch --allow-network` (which gates network flags inside a manifest). Every request + goes through an SSRF guard and never follows redirects; a failed opt-in fetch maps to + the stable `E_NETWORK` code — never a silent fallback to an unprotected result. + Nothing else touches the network — including `govern` — and `--dry-run` never does, + even when a network flag is present. - **No secrets in output.** `sign` never emits key material — errors are the fixed `E_SIGN` / "Failed to sign PDF." Pass keys via `PDFNATIVE_SIGN_KEY` / `PDFNATIVE_SIGN_CERT` (env wins over `--key` / `--cert`). Native `node:crypto` signing is @@ -210,10 +284,13 @@ For `verify`/`inspect`, read the JSON result on stdout and use `--strict` / merging encrypted sources with **different** passwords fails with `E_PASSWORD`. Decrypt the outliers first, then merge. (`split` / `extract` take a single input — no ambiguity.) - **Bounded input.** JSON input is capped at 50 MB; paths are checked against - traversal. `merge` / `split` / `extract` also honour `--max-output-size`. Prefer + traversal. `merge` / `split` / `extract` also honour `--max-output-size`, and the + global `--max-inflate-size ` caps the decompressed size of any single PDF + stream while parsing untrusted input (anti zip-bomb; default 100 MiB). Prefer `--output ` over shell redirection for large PDFs. -- **Incremental, signature-safe edits.** `annotate` uses an incremental save, so existing - signatures on the input stay valid. +- **Incremental, signature-safe edits.** `annotate`, `metadata`, `ltv embed`/`add`, and + `doc-timestamp` use incremental saves, so existing signatures on the input stay valid + (`doc-timestamp` keeps earlier revisions byte-identical). - **Human-in-the-loop for governance.** `govern` never submits anything; it only drafts and verifies. A human must review and submit under their own identity (see §6). - **One process per task.** The CLI is stateless; run it per unit of work and let diff --git a/CHANGELOG.md b/CHANGELOG.md index 938abf5..6f91b0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,203 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.4.0] – 2026-08-26 + +Built on **pdfnative 1.7.0**. Completes the PAdES ladder promised on the roadmap — trusted +timestamps at signing time (B-T), long-term validation data (B-LT) and document timestamps +(B-LTA) — as `sign --timestamp` plus two new commands (`ltv`, `doc-timestamp`), and adds two +more (`metadata`, `compare`), multi-signature support, declarative `batch --manifest` +pipelines, print production, PDF/A strict diagnostics, charts v2, and CLI-resolvable image +blocks. 17 → **21 commands**, one new stable error code (`E_NETWORK`), four new `schema` +subjects. Network I/O remains strictly opt-in and SSRF-guarded. 100% backward-compatible +command surface; the support policy moves to Node.js ≥ 22 (see *Changed*). + +### Added + +#### New commands + +- **`ltv`** — PAdES **B-LT** long-term validation: archive certificates, OCSP responses and + CRLs into `/DSS` + `/VRI` via pdfnative 1.7.0 `collectValidationInfo` / + `embedValidationInfo` / `addValidationInfo`. `ltv collect --online` fetches revocation data + (SSRF-guarded, no redirects) and emits a **replayable JSON file** (schema subject + `ltv-data`); `ltv embed --data` embeds it **fully offline** (air-gapped pipelines); + `ltv add --online` does both in one pass. `--prefer ocsp|crl`, `--extra-cert` (repeatable), + `--timeout`, `--dry-run`. Without `--online`, collect/add refuse to run (exit 2). +- **`doc-timestamp`** — PAdES **B-LTA** document timestamps: append a `/DocTimeStamp` + signature field (`/SubFilter /ETSI.RFC3161`, ISO 32000-2 §12.8.5) covering every byte as an + incremental revision via `addDocumentTimestamp`; earlier revisions stay byte-identical, and + the command can be repeated to renew LTA protection. `--url ` (required — the explicit + network opt-in), `--digest sha256|sha384|sha512`, `--field-name`, `--placeholder-bytes`, + `--nonce`, `--timeout`, `--dry-run`. +- **`metadata`** — update `/Info` + XMP metadata (`title`, `author`, `subject`, `keywords`, + `--mod-date`, or a `--from-json` object) via pdfnative 1.7.0 `PdfModifier.updateMetadata`. + The save is **incremental**: the original bytes are preserved as a prefix, so existing + digital signatures remain valid for their revision. `--password`, `--dry-run`. +- **`compare`** — diff two PDFs by extracted **text** and/or **structure** (page count, + page/print boxes with `--tolerance`, metadata, form fields, annotations, encryption, + signatures via `listSignatures`). Identical documents exit 0; any difference exits 1 with + the stable code `E_CHECK_FAILED` after printing the report — built for CI gates and agents. + `--mode text|structure|both`, `--format text|json` (schema subject `compare`), + `--ignore-whitespace`, `--pages`, `--password-a`/`--password-b`. Visual/rasterised diffing + stays out of scope (no rasteriser upstream). + +#### `sign` + +- **`--timestamp ` is now functional** (PAdES **B-T**) — the flag was reserved since + v1.1.0 and errored with `E_UNSUPPORTED`, as its own message announced. The CLI builds the + RFC 3161 request, POSTs it through the same SSRF guard as `verify --revocation online`, and + pdfnative 1.7.0 `signPdfBytesWithTimestamp` verifies and embeds the token in the CMS + unsigned attributes. `--timestamp-digest sha256|sha384|sha512`, `--timestamp-nonce `. + TSA transport failures are `E_NETWORK`; malformed responses are `E_PARSE`; there is **no + silent fallback** to an untimestamped signature, and `--dry-run` never touches the network. + The `--json` envelope gains `timestamp: { url, digest }`. +- **Multiple signatures** — `--allow-multiple` (default off: the 1.x idempotent + single-signature behaviour is preserved), `--field-name` for named signature fields. +- **`--profile pkcs7|pades`** (`pades` = `ETSI.CAdES.detached` with ESS + signing-certificate-v2) and **`--digest sha256|sha384|sha512`** (RSA; ECDSA stays + sha256-only). +- **Visible signature placement** — `--signature-rect "x1,y1,x2,y2"`, `--signature-page `, + and `--placeholder-bytes ` for oversized chains/tokens. + +#### `verify` + +- **SHA-384/512 CMS signatures verify** (`rsa-sha384` / `rsa-sha512` reported in + `signatureAlgorithm`) — matching what `sign --digest` can now produce. +- **`/DocTimeStamp` revisions are validated** as RFC 3161 tokens (imprint check against the + byte range) and reported with `isDocTimestamp: true`; each signature also reports its + `fieldName`. Both fields are additive. + +#### `inspect` + +- **`--signatures`** — structural signature inventory via pdfnative 1.7.0 `listSignatures`: + `fieldName`, `subFilter`, `byteRange`, `isDocTimestamp`, `isPlaceholder`, `sigObjNum`, + `contentsLength` (never the signature bytes). Without the flag the output is unchanged. +- **Print-production reads** — `--pages` now reports `cropBox` / `trimBox` / `bleedBox` / + `artBox` and `userUnit` when present; `metadata` gains `trapped`. +- **`--check "signatures>=N"`** — assert a minimum count of real (non-placeholder, + non-timestamp) signatures. The existing `signed` check now counts on the same basis + (unsigned placeholders and `/DocTimeStamp` revisions no longer count) — a correctness + fix: a placeholder-only PDF passed `--check signed` in 1.3.0 and now fails. + +#### `render` + +- **`--strict`** — escalate PDF/A conformance diagnostics (`PDFA_NO_FONT_ENTRIES`, + `PDFA_UNEMBEDDED_FORM_FONT`, `PDFA_DEVICE_CMYK_IMAGE`) into an error **before any output + byte** (exit 1, `E_CHECK_FAILED`). Without it, diagnostics surface as `warning:` lines on + stderr (suppressed by `--quiet`) and as an additive `diagnostics[]` array in the `--json` + envelope. +- **Image blocks are now usable from JSON** — `{ "type": "image", "src": "logo.png" }` (path + resolved against the input JSON's directory, same validation as `--attachment`) or + `{ "dataBase64": "…" }` (inline JPEG/PNG); the CLI resolves both to bytes before rendering. +- **Print production** (pdfnative 1.7.0, via `--layout` JSON) — `print.bleed` / + `trimBox` / `bleedBox` / `artBox` / `cropBox`, vector printer marks (`print.marks`), + `userUnit` (1–75 000), and a custom RGB ICC `outputIntent`. +- **Viewer print preferences** — `viewerPreferences.duplex`, `pickTrayByPDFSize`, + `printPageRange`, `numCopies`. +- **Charts v2** — 9 chart kinds (+`stackedBar`, `stackedBarH`, `area`, `scatter`), secondary + Y axis (`series.yAxis: "right"` + `axis2`), `xAxis` with `category|linear|time` types, + logarithmic scale, `dataLabels`, `labelStride`, `labelRotation`. +- **Document metadata** — `params.metadata` (`author`, `subject`, `keywords`, + `trapped: True|False|Unknown`) → `/Info` + XMP. +- **`--chunk-size `** for `--stream` / `--stream-true` (parity with the page-tree + commands). + +#### `annotate` + +- **`--password`** (or `$PDFNATIVE_PASSWORD`) — annotate encrypted PDFs; appended objects are + encrypted under the document's existing scheme. + +#### `batch` + +- **`--manifest tasks.json`** — declarative multi-command pipelines + (`{ "version": 1, "tasks": [{ "id", "command", "flags" }] }`, schema subject + `batch-manifest`): flag values `"@"` reference the output of an earlier task, relative + paths resolve against the manifest's directory, tasks run sequentially fail-fast + (`--continue-on-error` to keep going), and the whole manifest is validated before anything + executes (manifests are size-capped like every other JSON input, bounded to 1 000 + tasks, and path values get the same traversal check as direct CLI flags — a manifest + has the filesystem access of its invoker, no more). 14 commands are whitelisted + (never `batch`/`govern`/`schema`/`completion`/`doctor`; `ltv` and `compare` need + positional arguments and are not manifest-callable yet). **`--allow-network` is + required** for any network flag inside a manifest — a manifest obtained from + elsewhere can never trigger network I/O on its own. `--dry-run` prints the plan. + +#### Agent surface + +- **New stable error code `E_NETWORK`** — an explicitly requested TSA / OCSP / CRL fetch + failed. Published automatically in `schema manifest`. +- **Four new `schema` subjects** — `ltv-data`, `compare`, `batch-manifest`, `metadata` + (15 → 19); the `render`, `inspect`, `verify`, `batch` and `status` schemas were + extended with all the additive fields above. +- **Global `--max-inflate-size `** — cap the decompressed size of any single PDF + stream while parsing untrusted input (anti zip-bomb; default 100 MiB), via pdfnative's + `setMaxInflateOutputSize`. + +#### Tooling & tests + +- **Offline mock PKI** (`tests/helpers/mock-pki.ts`) — a deterministic root CA + signer + TSA + + OCSP responder issuing *genuine* RFC 3161 tokens, OCSP responses and CRLs entirely + in-process, so the whole PAdES ladder is tested with **zero network** and zero binary + fixtures. 600 tests total (up from 452). +- **veraPDF PDF/A validation gate** — the CLI's PDF/A claims are now validated against the + [veraPDF](https://verapdf.org) reference validator. `npm run corpus:pdfa` drives the built + CLI to generate a 12-file corpus (10 positive entries across 1b/2b/2u/3b — including an + incremental PAdES signature and a `metadata` update over claiming files — plus 2 **negative + canaries** veraPDF must reject: a no-fonts render violating ISO 19005-2 §6.2.11.4.1 and a + `--variant table` render violating ISO 19005-1 §6.3.4, since that path cannot embed fonts + from the CLI); `npm run validate:pdfa` checks each file against the profile it claims in XMP + (exit 0 ok/skip · 1 conformance · 2 no corpus · 3 infra; an unexpected canary pass — XPASS — + is fatal). Without veraPDF installed the run **skips with exit 0** (not a pass); + `VERAPDF_REQUIRED=1` fails closed. **Blocking in CI** (`.github/workflows/verapdf.yml`, + pinned veraPDF 1.30.2 installer with SHA-256 verification before `java -jar`) and repeated + as a pre-publish gate in `publish.yml`. veraPDF is an external CI tool, never bundled — zero + extra runtime dependencies unchanged. + +### Changed + +- **`pdfnative` bumped** to `^1.7.0` (was `^1.6.0`). Inherited engine improvements the CLI + surfaces without code changes: **merge/split/extract now preserve BleedBox / TrimBox / + ArtBox and `/UserUnit`** (previously stripped), corrected RTL shaping (UAX #9 L4 mirroring, + Arabic ALEF joining), searchable text in form-bearing documents (full base-14 `/ToUnicode`), + and colour-emoji flag/ZWJ sequences. Form-bearing and RTL documents therefore change bytes + versus 1.3.0 output — outputs remain spec-valid; no CLI contract changes. +- **Support policy: Node.js ≥ 22** (was ≥ 20) and CI now tests Node 22 + 24. Node 20 reached + end-of-life on 2026-04-30, and pdfnative 1.7.0 — the CLI's only runtime dependency — + declares `engines.node >= 22`. This is a support-policy change, not an API change: the + command surface, exit codes, error codes and envelopes are 100% backward-compatible. +- **PDF/A diagnostics routing** — pdfnative 1.7.0's conformance diagnostics (previously + `console.warn` inside the engine) are now routed through the CLI: `warning:` lines on + stderr, `diagnostics[]` under `--json`, or a hard error under `render --strict`. +- Dev-dependency security overrides refreshed (`js-yaml ^4.3.1`, `nanoid ^3.3.18`); + `npm audit` clean. + +### Fixed + +- **`inspect` signature/form-field counters were always 0** — the legacy counters compared + parsed PDF name objects against raw strings (`'/Sig'`, `'/Widget'`), which never matched, + so `signatures` and per-page `formFields` under-reported on every signed PDF. Both now go + through the parser's `nameValue`. +- **PDF/A samples now render actually-conformant outputs** — `samples/run-all.js` renders the + `render/pdfa/` and `render/attachments/` samples with `--font latin --lang latin`, embedding + the bundled Latin font. Previously those samples rendered without embedded fonts, so their + outputs claimed PDF/A in XMP but violated the ISO 19005 font-embedding requirements + (non-embedded base-14 Helvetica) and did not pass the reference validator. The blocking + veraPDF CI gate now guards this recipe (see *Tooling & tests*). + +### Documentation + +- README, `docs/KNOWLEDGE_BASE.md`, `AGENTS.md`, `llms.txt`, `ROADMAP.md`, `samples/README.md` + and `CITATION.cff` updated for the 21-command surface; `CITATION.cff` re-synchronised + (was still 1.2.0 / "six composable commands"). +- Corrected a false claim that `svg` blocks were not usable from JSON (`SvgBlock.data` is a + string and has worked since pdfnative 1.5.0), and documented the previously missing `math` + entry in `render --font`. +- New runnable samples: `sign/06-timestamp` (replaces `06-timestamp-reserved`), `sign/08-ltv` + (the full PAdES ladder), `sign/09-multiple-signatures`, `inspect/08-list-signatures`, + `metadata/01-update-metadata`, `compare/01-compare`, `batch/03-manifest`, plus print + production and charts-v2 render JSONs — every pair ships as `.sh` **and** `.ps1`, all + offline by default (network steps opt-in via `PDFNATIVE_TSA_URL`). + ## [1.3.0] – 2026-07-24 Built on **pdfnative 1.6.0**. Surfaces the engine's 1.6.0 additions on the CLI as five new diff --git a/CITATION.cff b/CITATION.cff index cbada35..0884d4b 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -5,18 +5,19 @@ message: >- title: "pdfnative-cli — Official CLI for the pdfnative PDF generation library" abstract: >- A zero-dependency command-line interface for pdfnative — a pure-TypeScript, - ISO 32000-1 compliant PDF generation library. pdfnative-cli exposes six - composable commands: render (JSON → PDF, including PDF/A conformance, 22 - Unicode scripts, COLRv1 colour emoji, and true constant-memory streaming), - sign (CMS/PKCS#7 digital signatures via RSA or ECDSA), inspect (structured - PDF metadata analysis and PDF/UA structural validation), verify (CMS/PKCS#7 - signature verification with certificate-chain, trust, RFC 3161 timestamp and - OCSP/CRL revocation evaluation), batch (parallel directory rendering), and - completion (shell-completion scripts). An agent-native contract — a global - --json envelope, stable error codes, --dry-run, and a schema command — - lets autonomous AI agents and CI pipelines drive the CLI deterministically. - Designed for shell pipelines, CI/CD automation, and server-side document - generation. + ISO 32000-1 compliant PDF generation library. pdfnative-cli exposes 21 + composable commands covering the full document lifecycle: rendering JSON + document definitions to PDF (PDF/A conformance, 22 Unicode scripts, native + vector charts, print production, streaming), reading-order text extraction, + AcroForm filling and flattening, page-tree operations (merge, split, + extract), AES-128/256 encryption and decryption, metadata editing, + annotation, and CMS/PKCS#7 digital signatures with RFC 3161 timestamps and + long-term validation (PAdES B-T / B-LT / B-LTA), plus signature + verification, text/structural PDF comparison, and multi-step pipeline + orchestration (batch manifests). An agent-first contract — a global --json + envelope, stable error codes, --dry-run, versioned JSON Schemas and a + capability manifest — lets autonomous AI agents and CI pipelines drive the + CLI deterministically under a human-in-the-loop governance model. type: software authors: - name: "Nizoka" @@ -25,8 +26,8 @@ authors: repository-code: "https://github.com/Nizoka/pdfnative-cli" url: "https://pdfnative.dev" license: MIT -version: 1.2.0 -date-released: "2026-07-06" +version: 1.4.0 +date-released: "2026-08-26" keywords: - pdf - cli diff --git a/CLAUDE.md b/CLAUDE.md index d4c6824..edb121c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,8 +26,10 @@ this file wins on Claude-Code workflow specifics. 3. **Agent-first.** stdout = artifact, stderr = diagnostics/envelopes, stable exit codes (0/1/2) and stable `E_*` error codes. Keep the machine contract deterministic; agent mode is a thin presentation layer, never a second runtime. -4. **Offline by default.** Only `verify --revocation online` makes network - requests, always through the SSRF guard. +4. **Offline by default.** Network I/O happens only behind explicit opt-in + flags (`verify --revocation online`, `sign --timestamp`, `doc-timestamp + --url`, `ltv --online`, `batch --allow-network`), always through the SSRF + guard. `--dry-run` never touches the network. 5. **Native constant-time crypto** for signing by default (`node:crypto`); `--pure-crypto` opts into pdfnative's portable path. Never log key material or passwords. @@ -71,6 +73,9 @@ npm run typecheck:all # tsc for src + tests — must be clean npm run lint # eslint src/ — 0 errors npm run test # vitest run — all pass; keep coverage ≥ thresholds npm run build # tsup → dist/cli.cjs (the bin) +npm run validate:pdfa # veraPDF gate for PDF/A changes (external veraPDF + # required; absent → exit 0 = SKIP, not a pass — + # VERAPDF_REQUIRED=1 fails closed; blocking in CI) ``` Coverage thresholds live in `vitest.config.ts` (statements/branches/functions/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 174a7d8..c65e12c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,7 @@ npm install ### Requirements -- Node.js >= 20 +- Node.js >= 22 - npm >= 9 ## Build @@ -28,9 +28,104 @@ npm run dev # tsup --watch npm run test # vitest run npm run test:watch # vitest (watch mode) npm run test:coverage # vitest with v8 coverage +npm run corpus:pdfa # generate the PDF/A validation corpus (needs a prior npm run build) +npm run validate:pdfa # build + corpus + veraPDF validation (see below) ``` -All new code must include tests. Coverage thresholds: statements 75%, branches 80%, functions 85%, lines 75%. +All new code must include tests. Coverage thresholds (enforced by `vitest.config.ts`, the single source of truth): statements 79%, branches 68%, functions 83%, lines 79%. Never lower them to make a change pass — add tests. + +## PDF/A validation (veraPDF) + +The CLI's PDF/A claims are checked against the official reference validator, +[veraPDF](https://verapdf.org). `npm run validate:pdfa` builds `dist/cli.cjs`, +drives the **built** CLI to write a 12-file corpus to `test-output/pdfa/` +(`scripts/generate-pdfa-corpus.mjs` — a representative sample, not an exhaustive +feature matrix: renders at all four levels 1b / 2b / 2u / 3b, a PDF/A-3b XML +attachment, header/footer templates, `--outline auto`, an opaque watermark, an +incremental PAdES signature over a claiming file, and an incremental `metadata` +update), then validates each file against the profile it claims in XMP with +`scripts/validate-pdfa.mjs` and compares the verdict with the manifest's +`expectCompliant` flag. + +The positive entries render with `--strict --font latin --lang latin`: the CLI +has no `embedFonts` switch — `--font latin` registers the bundled Noto Sans +loader and `--lang latin` injects the matching `fontEntries`, which routes all +Latin text away from non-embedded base-14 Helvetica (the sRGB OutputIntent is +emitted automatically by the engine). The corpus also includes **two negative +canaries** (`expectCompliant: false`) that veraPDF **must reject** — otherwise +the validator is accepting everything and the run fails: + +- a render without fonts, whose claim violates ISO 19005-2 §6.2.11.4.1 (fonts + used for rendering shall be embedded); +- a `--variant table` render under PDF/A-1b — the `PdfParams` path has no + `fontEntries` channel, so the CLI **cannot** embed fonts on it and the claim + violates ISO 19005-1 §6.3.4. This is a documented CLI gap, kept as a canary. + +An unexpected pass (`XPASS`) is always fatal, and a coverage canary fails the +run if a manifest file is missing or its XMP claim disagrees with the manifest. + +Per file the validator reports `PASS` / `FAIL` / `XFAIL` / `XPASS` / `INFRA` / +`SKIP`. Exit codes of `scripts/validate-pdfa.mjs`: + +| Exit | Meaning | +|------|---------| +| 0 | Every expectation met — **or** veraPDF is absent and `VERAPDF_REQUIRED` is unset: install hints are printed and validation is **SKIPPED** (exit 0 is a skip, not a pass) | +| 1 | Conformance expectation not met (`FAIL` or `XPASS`), no negative canary in the corpus, or the coverage canary tripped | +| 2 | Corpus directory / manifest absent — run `npm run corpus:pdfa` first | +| 3 | `INFRA`: veraPDF/Java unusable (fatal only with `VERAPDF_REQUIRED=1` — otherwise a skip), or at least one file produced an INFRA outcome (always fatal) — not a conformance verdict | + +Environment: `VERAPDF_HOME=` points at a veraPDF install (`verapdf` / +`verapdf.bat` at the root or under `bin/`); `VERAPDF_REQUIRED=1` fails closed +(missing veraPDF → exit 3 instead of a skip; set in CI, unset locally); +`VERAPDF_REPORT_DIR=` relocates the raw per-file veraPDF XML reports +(default `test-output/pdfa/reports/`). + +**CI is blocking**: the same scripts run with `VERAPDF_REQUIRED=1` and a pinned +veraPDF **1.30.2** whose installer SHA-256 is verified before `java -jar` +executes it, on every push / PR touching `src/`, `samples/`, `scripts/` or the +package manifest (`.github/workflows/verapdf.yml`), and again as a pre-publish +gate in `.github/workflows/publish.yml`. veraPDF is an external CI tool, not a +dependency — the zero-extra-runtime-dependency policy is unchanged. + +Installing veraPDF locally (Java 8+ required): + +```bash +# macOS +brew install --cask verapdf + +# Linux (headless, no GUI — same mechanism as CI; adjust the install path) +curl -fsSL -o installer.zip https://software.verapdf.org/rel/1.30/verapdf-greenfield-1.30.2-installer.zip +unzip installer.zip && cd verapdf-greenfield-* +cat > auto-install.xml <<'XML' + + + + /opt/verapdf + + + + +XML +java -jar verapdf-izpack-installer-*.jar auto-install.xml +export VERAPDF_HOME=/opt/verapdf +``` + +```powershell +# Windows — download the same installer zip, unzip, and run the GUI installer +# (or the headless recipe above with a Windows ). The install +# directory contains verapdf.bat; point VERAPDF_HOME at it: +$env:VERAPDF_HOME = "C:\Program Files\veraPDF" +``` + +Then expose it: add the install directory to `PATH`, or set `VERAPDF_HOME` to +it. Windows note: the `.bat` launcher is invoked through a shell with quoted +arguments (Node refuses to spawn batch files directly), so paths with spaces +work. + +**PR checklist**: if you change samples or anything affecting PDF/A behaviour +(`render`, fonts, metadata/XMP, signing over claiming files), make sure +`npm run validate:pdfa` passes locally with veraPDF installed — remember that +exit 0 without veraPDF is a skip, not a proof. ## Lint & Type Check diff --git a/README.md b/README.md index 655d132..08c94e4 100644 --- a/README.md +++ b/README.md @@ -16,16 +16,18 @@ Official CLI for the [`pdfnative`](https://github.com/Nizoka/pdfnative) library — render JSON to PDF, apply digital signatures, verify them, and inspect PDF conformance, directly from the terminal. Zero extra runtime dependencies. -> **What's new in v1.3.0** — built on **pdfnative 1.6.0**. Five new commands: **`extract-text`** -> (reading-order text as text / JSON / **NDJSON** for RAG & agents — no OCR), **`fill`** (fill, -> flatten & **export** AcroForms with an incremental save), **`encrypt`** / **`decrypt`** -> (AES-128/256), and a **`doctor`** environment/capability preflight. `render` gains **native -> vector charts** (bar, barH, line, pie, donut). `merge` / `split` / `extract` gain -> **`--password`**, **`--encrypt`**, and constant-memory **`--stream`**. Adds a machine-readable -> **capability manifest** (`schema manifest` + `llms.txt`), **PowerShell** completion, a grouped -> `--help`, and the stable **`E_PASSWORD`** code. Unifies the `render` encryption flags -> (`--encrypt` / `--owner-password`) with the page-tree commands. 100% backward-compatible. -> See [release notes](release-notes/v1.3.0.md) and [AGENTS.md](AGENTS.md). +> **What's new in v1.4.0** — built on **pdfnative 1.7.0**. Four new commands: **`ltv`** +> (PAdES B-LT: collect/embed OCSP+CRL validation data into `/DSS` + `/VRI`, air-gap-friendly), +> **`doc-timestamp`** (PAdES B-LTA: RFC 3161 `/DocTimeStamp` revisions), **`metadata`** +> (update `/Info` + XMP with an incremental save — signatures stay valid), and **`compare`** +> (text + structure diff with CI-friendly exit codes). `sign --timestamp ` is now +> **functional** (RFC 3161, PAdES B-T), with **multi-signatures** (`--allow-multiple`), +> `--profile pades`, sha384/512 digests, and visible signature placement. `render` gains +> **`--strict`** PDF/A gating, **charts v2** (9 types, dual axes, log/time scales), and +> **print production** (bleed/trim boxes, output intents, viewer preferences). `batch` gains +> declarative **`--manifest`** pipelines. Adds the stable **`E_NETWORK`** code and a global +> **`--max-inflate-size`** anti-zip-bomb cap. Zero breaking changes; **Node ≥ 22** now required. +> See [release notes](release-notes/v1.4.0.md) and [AGENTS.md](AGENTS.md). > > ⭐ Star [`pdfnative`](https://github.com/Nizoka/pdfnative) — the zero-dependency PDF engine that powers this CLI. @@ -49,13 +51,35 @@ Official CLI for the [`pdfnative`](https://github.com/Nizoka/pdfnative) library `--location`, `--contact`, `--signing-time`) and intermediate CA chains via `--cert-chain` (repeatable). Uses **native `node:crypto`** for constant-time signatures by default (`--pure-crypto` opts out). Keys loaded from env vars or files; never logged. + v1.4.0 adds a **functional RFC 3161 `--timestamp `** (PAdES B-T), **PAdES + profile** (`--profile pades` — ETSI.CAdES.detached, ESS signing-certificate-v2), + **sha384/512 digests** (RSA), **multi-signatures** (`--allow-multiple` / `--field-name`), + and **visible signature placement** (`--signature-rect` / `--signature-page`). +- **PAdES long-term signatures (v1.4.0, pdfnative 1.7.0)** — the full B-T → B-LT → B-LTA + ladder: `sign --timestamp --profile pades` (B-T) → `ltv add --online` (B-LT: OCSP + + CRL validation data archived into `/DSS` + `/VRI`) → `doc-timestamp --url ` (B-LTA: + a `/DocTimeStamp` revision, repeatable for renewal) → `ltv add --online`. The `ltv + collect` / `ltv embed` split supports **air-gapped pipelines** — collect on a connected + machine, embed fully offline. All network access is explicit opt-in and SSRF-guarded. +- **`metadata` / `compare`** (v1.4.0) — update `/Info` + XMP metadata with an + **incremental save** (existing signatures stay valid); diff two PDFs by **text + + structure** with CI-friendly exit codes (identical → 0, different → 1 / `E_CHECK_FAILED`). +- **`batch --manifest`** (v1.4.0) — a declarative, sequential multi-command pipeline + (`render` → `sign` → `verify` → …) in one JSON file, with `@` output references and + an `--allow-network` gate so untrusted manifests can never trigger network I/O. +- **Print production & charts v2** (v1.4.0, pdfnative 1.7.0) — `render` layout gains + bleed/trim/art/crop boxes, printer marks, `userUnit`, ICC output intents, and viewer + preferences (duplex, `numCopies`, …); charts grow to **9 types** (stacked bars, area, + scatter) with dual axes, log/time/category x-axes, and data labels. `render --strict` + turns PDF/A conformance diagnostics into a hard error before the first output byte. - **`inspect`** — PDF version, page count, encryption, PDF/A conformance, signature count, metadata, **page labels**, **markup/link annotations** (`--annotations`), and **PDF/UA (ISO 14289-1) structural validation**. `--verbose`, `--pages`, `--pdfua`, and `--check pdfa|signed|encrypted|pdfua` for CI assertions. - **`verify`** — verify every CMS/PKCS#7 signature: byte-range integrity, RSA/ECDSA - signature value, certificate chain, trust roots, **RFC 3161 timestamp (PAdES-T)**, and - **OCSP + CRL revocation** (embedded `/DSS` offline by default, opt-in SSRF-guarded online). + signature value (RSA now also sha384/512), certificate chain, trust roots, **RFC 3161 + timestamp (PAdES-T)**, **`/DocTimeStamp` revisions (PAdES B-LTA)**, and **OCSP + CRL + revocation** (embedded `/DSS` offline by default, opt-in SSRF-guarded online). JSON & text output, `--strict`, `--revocation`, `--revocation-policy`. - **`merge` / `split` / `extract`** — page-tree operations (pdfnative 1.5.0): concatenate several PDFs, split one PDF into many (per-page or per-range), or pull selected pages into @@ -84,7 +108,8 @@ Official CLI for the [`pdfnative`](https://github.com/Nizoka/pdfnative) library precedence is CLI flags > env > config. - **Zero extra dependencies** — `pdfnative` is the sole runtime dependency. - **Offline by default** — no network access unless you explicitly opt in with - `verify --revocation online`, and even then every request passes an SSRF guard. + `verify --revocation online`, `sign --timestamp`, `ltv --online`, `doc-timestamp --url`, + or `batch --allow-network` — and every request passes an SSRF guard (no redirects). - **Stdin / stdout by default** — every command is shell-pipeline friendly. - **Secret-safe** — signing keys, certs, encryption passwords never appear in error output or stderr. PEM material redacted; layout-file `attachments[].data` injection blocked. @@ -115,9 +140,9 @@ Official CLI for the [`pdfnative`](https://github.com/Nizoka/pdfnative) library | `.pdfnativerc.json` config file | ✅ | Global + per-command defaults; flags > env > config | | **Agent / automation** | | | | Global `--json` envelope | ✅ | Status on success, `{ ok, error: { code, message } }` on failure | -| Stable error codes | ✅ | `E_USAGE`, `E_INPUT`, `E_PARSE`, `E_SIGN`, `E_VERIFY_FAILED`, `E_POLICY`, `E_PASSWORD`, … | +| Stable error codes | ✅ | `E_USAGE`, `E_INPUT`, `E_PARSE`, `E_SIGN`, `E_VERIFY_FAILED`, `E_POLICY`, `E_PASSWORD`, `E_NETWORK`, … | | Capability manifest | ✅ | `schema manifest` (JSON) + `llms.txt` — for agent tool discovery | -| `--dry-run` validation | ✅ | `render` / `sign` / `batch` / `merge` / `split` / `extract` / `annotate` / `fill` / `encrypt` / `decrypt` | +| `--dry-run` validation | ✅ | `render` / `sign` / `batch` / `merge` / `split` / `extract` / `annotate` / `fill` / `encrypt` / `decrypt` / `metadata` / `ltv` / `doc-timestamp` | | **Document Blocks** | | | | Headings, paragraphs, lists | ✅ | Full text styling support | | Tables | ✅ | Headers, rows, multi-page | @@ -128,7 +153,7 @@ Official CLI for the [`pdfnative`](https://github.com/Nizoka/pdfnative) library | Page breaks, spacers | ✅ | Explicit pagination control | | Table of contents | ✅ | Auto-generated with `/GoTo` links | | **Advanced Layouts (v0.2.0)** | | | -| PDF/A archival (1b, 2b, 2u, 3b) | ✅ | `--tagged pdfa` (preferred) or `--conformance` (deprecated) | +| PDF/A archival (1b, 2b, 2u, 3b) | ✅ | `--tagged pdfa` (preferred) or `--conformance` (deprecated); validated against the **veraPDF** reference validator in CI (blocking) | | Streaming output | ✅ | `--stream` (single-pass) for large documents | | Compression | ✅ | `--compress` flag | | Encryption (AES-128/256) | ✅ | `--encrypt-*` flags + env-var precedence | @@ -156,7 +181,7 @@ Official CLI for the [`pdfnative`](https://github.com/Nizoka/pdfnative) library | OCSP revocation (RFC 6960) | ✅ | Embedded `/DSS` + opt-in online via AIA (SSRF-guarded) | | CRL revocation (RFC 5280) | ✅ | Embedded `/DSS` + opt-in online via CDP (SSRF-guarded) | | Revocation policy | ✅ | `--revocation offline\|online\|disabled`, `--revocation-policy soft-fail\|strict` | -| Sign-side LTV (timestamp embedding / DSS) | ⚠️ | Upstream-blocked in pdfnative; `sign --timestamp` reserved | +| Sign-side LTV (timestamp embedding / DSS) | ✅ | v1.4.0: `sign --timestamp`, `ltv collect\|embed\|add`, `doc-timestamp` (see below) | | **Render iteration** | | | | Smart tables | ✅ | `--table-wrap`, `--repeat-header`, `--zebra`, `--cell-padding`, `--min-row-height` | | Page-by-page streaming | ✅ | `--stream-page-by-page` (TOC- and `{pages}`-compatible) | @@ -202,9 +227,44 @@ Official CLI for the [`pdfnative`](https://github.com/Nizoka/pdfnative) library | Environment preflight | ✅ | `doctor` (versions, Web Crypto/CSPRNG, command count; text or `--json`) | | Capability manifest | ✅ | `schema manifest` + `llms.txt` for agent tool discovery | | PowerShell completion | ✅ | `completion powershell` | - -**Note:** features marked **⚠️** are tracked in [ROADMAP.md](ROADMAP.md). Everything else -works today. +| **Long-term signatures & document ops (v1.4.0, pdfnative 1.7.0)** | | | +| RFC 3161 signing timestamp (PAdES B-T) | ✅ | `sign --timestamp ` (+ `--timestamp-digest`, `--timestamp-nonce`); opt-in network, SSRF-guarded | +| PAdES signature profile | ✅ | `sign --profile pades` (ETSI.CAdES.detached, ESS signing-certificate-v2) | +| CMS digest selection | ✅ | `sign --digest sha256\|sha384\|sha512` (RSA; ECDSA is sha256-only) | +| Multiple signatures | ✅ | `sign --allow-multiple` + `--field-name` (default stays idempotent single-signature) | +| Visible signature placement | ✅ | `sign --signature-rect "x1,y1,x2,y2"` + `--signature-page` (+ `--placeholder-bytes`) | +| LTV validation data (PAdES B-LT) | ✅ | `ltv collect\|embed\|add` — OCSP + CRL into `/DSS` + `/VRI`; `collect` needs `--online`, `embed` is fully offline (air-gapped) | +| Document timestamp (PAdES B-LTA) | ✅ | `doc-timestamp --url ` — `/DocTimeStamp` (ETSI.RFC3161) incremental revision, repeatable for renewal | +| Verify B-LTA / sha384-512 | ✅ | `verify` validates `/DocTimeStamp` revisions as RFC 3161 tokens; accepts `rsa-sha384` / `rsa-sha512`; reports `fieldName` + `isDocTimestamp` | +| Signature inventory | ✅ | `inspect --signatures` (fieldName, subFilter, byteRange, isDocTimestamp, isPlaceholder — never signature bytes) + `--check "signatures>=N"` | +| Metadata editing | ✅ | `metadata --title/--author/--subject/--keywords/--mod-date` or `--from-json` — incremental save, signatures stay valid | +| PDF comparison | ✅ | `compare a.pdf b.pdf --mode text\|structure\|both` — exit 0 identical, exit 1 / `E_CHECK_FAILED` on differences; no visual diff | +| Manifest pipelines | ✅ | `batch --manifest tasks.json` — sequential fail-fast tasks, `@` references, `--allow-network` gate, `--continue-on-error` | +| PDF/A strict gating | ✅ | `render --strict` — conformance diagnostics become `E_CHECK_FAILED` before any output byte (else stderr warnings + `diagnostics[]` under `--json`) | +| Charts v2 | ✅ | 9 types (+ stackedBar, stackedBarH, area, scatter), `series.xValues`, `yAxis: right` + `axis2`, log scale, category/linear/time x-axis, data labels, label stride/rotation | +| Print production | ✅ | `layout.print` (bleed, trimBox, bleedBox, artBox, cropBox, marks, userUnit 1–75000), `layout.outputIntent` (ICC RGB), `layout.viewerPreferences` (duplex, pickTrayByPDFSize, printPageRange, numCopies) | +| Image blocks by path | ✅ | `{ "type": "image", "src": "logo.png" }` (relative to the input JSON) or `dataBase64` | +| Encrypted annotate | ✅ | `annotate --password` — appended objects re-encrypted under the existing scheme | +| Page-box preservation | ✅ | `merge` / `split` / `extract` now preserve BleedBox/TrimBox/ArtBox/UserUnit (pdfnative 1.7.0; previously dropped) | +| Anti zip-bomb cap | ✅ | Global `--max-inflate-size ` — cap on any decompressed stream while parsing (default 100 MiB) | +| Network error code | ✅ | Stable `E_NETWORK` — opt-in network operation failed (TSA / OCSP / CRL fetch) | + +**Note:** everything listed works today. Planned work is tracked in [ROADMAP.md](ROADMAP.md). + +### PDF/A status (v1.4.0) + +The CLI's PDF/A outputs are **validated against the [veraPDF](https://verapdf.org) +reference validator in CI (blocking)**: a 12-file corpus produced by the CLI itself +(renders across all four levels plus attachments, headers/footers, outline, watermark, +an incremental PAdES signature and a `metadata` update) is checked against the profile +each file claims in XMP, and it includes **negative canaries** that veraPDF must +reject — so a validator that accepts everything fails the run instead of turning it +green. The conformance recipe is `--tagged pdfa --font latin --lang latin` +(fonts must be embedded per ISO 19005; the sRGB OutputIntent is emitted automatically +by the engine). Run the same gate locally with `npm run validate:pdfa` — without +veraPDF installed it prints install hints and exits 0 as a **skip, not a pass**. See +[CONTRIBUTING.md](CONTRIBUTING.md#pdfa-validation-verapdf) for details. Not a +certification — validation evidence against a specific veraPDF version (1.30.2). ## Installation @@ -218,7 +278,7 @@ Or run without installing: npx pdfnative-cli render --input doc.json --output report.pdf ``` -**Requirements:** Node.js ≥ 20 | Bun | Deno (`node dist/cli.cjs`) +**Requirements:** Node.js ≥ 22 (Node 20 reached end-of-life 2026-04-30; CI runs 22/24) | Bun | Deno (`node dist/cli.cjs`) ## Documentation @@ -226,7 +286,7 @@ npx pdfnative-cli render --input doc.json --output report.pdf - 🏛️ **[KNOWLEDGE_BASE.md](docs/KNOWLEDGE_BASE.md)** — Full CLI reference, architecture, integration patterns - 📚 **[samples/README.md](samples/README.md)** — runnable samples organized by feature - 🔧 **[pdfnative library](https://github.com/Nizoka/pdfnative)** — Underlying PDF engine docs -- ❓ **[FAQ](docs/KNOWLEDGE_BASE.md#11-frequently-asked-questions)** — Common questions & troubleshooting +- ❓ **[FAQ](docs/KNOWLEDGE_BASE.md#12-frequently-asked-questions)** — Common questions & troubleshooting ## Quick Start @@ -245,8 +305,10 @@ pdfnative render --input big-doc.json --output report.pdf --stream # True constant-memory streaming (lowest peak memory; byte-identical) pdfnative render --input big-doc.json --output report.pdf --stream-true -# PDF/A conformance -pdfnative render --input document.json --output archived.pdf --conformance 2b +# PDF/A conformance (embed the bundled Latin font — ISO 19005 requires embedded +# fonts; this recipe is what the blocking veraPDF CI gate validates) +pdfnative render --input document.json --output archived.pdf \ + --tagged pdfa2b --font latin --lang latin ``` `document.json` is a [`DocumentParams`](https://github.com/Nizoka/pdfnative) object: @@ -308,7 +370,7 @@ Example output: "metadata": { "title": "Monthly Report", "author": "Nizoka", - "creationDate": "2026-04-27T12:00:00+00:00" + "creationDate": "D:20260427120000+00'00'" } } ``` @@ -353,6 +415,41 @@ pdfnative decrypt --input secure.pdf --output plain.pdf --password "$USER" pdfnative render --input dashboard.json --output dashboard.pdf ``` +### Long-term signatures, compare & metadata (v1.4.0) + +The canonical **PAdES ladder** — each rung is one command, network access is always +an explicit opt-in (`--timestamp` / `--online` / `--url`), SSRF-guarded, no redirects: + +```bash +# B-T — sign with a PAdES profile and an RFC 3161 trusted timestamp +pdfnative sign --input doc.pdf --output signed.pdf \ + --profile pades --timestamp https://tsa.example.com/tsr + +# B-LT — archive the OCSP/CRL validation data into /DSS + /VRI +pdfnative ltv add --input signed.pdf --output lt.pdf --online + +# B-LTA — append a document timestamp covering every byte (repeat to renew) +pdfnative doc-timestamp --input lt.pdf --output lta.pdf --url https://tsa.example.com/tsr +pdfnative ltv add --input lta.pdf --output archived.pdf --online + +# Air-gapped variant: collect on a connected machine, embed fully offline +pdfnative ltv collect --input signed.pdf --output ltv.json --online +pdfnative ltv embed --input signed.pdf --data ltv.json --output lt.pdf +``` + +```bash +# Diff two PDFs by text + structure (exit 0 identical, exit 1 on differences) +pdfnative compare original.pdf revised.pdf --mode both --format json + +# Update /Info + XMP metadata without breaking existing signatures (incremental save) +pdfnative metadata --input signed.pdf --output retitled.pdf \ + --title "Q3 Report (final)" --author "Finance Team" + +# Verify the whole ladder — /DocTimeStamp revisions are validated as RFC 3161 tokens +pdfnative verify --input archived.pdf --strict +pdfnative inspect --input archived.pdf --signatures --check "signatures>=1" +``` + ### Annotate a PDF (v1.2.0) ```bash @@ -421,13 +518,14 @@ Ready-to-run examples are in [`samples/`](samples/), organized by feature catego | [`render/form/`](samples/render/form/) | 2 files | Contact form, survey | | [`render/toc/`](samples/render/toc/) | 1 file | Document with auto-generated table of contents | | [`render/link/`](samples/render/link/) | 1 file | Resource directory with hyperlinks | -| [`render/watermark/`](samples/render/watermark/) | 2 files | Draft watermark, confidential watermark | +| [`render/watermark/`](samples/render/watermark/) | 3 files | Draft watermark, confidential watermark, CLI-flag styling | | [`render/layout/`](samples/render/layout/) | 3 files | US Letter, A5 portrait, A4 landscape | -| [`render/pdfa/`](samples/render/pdfa/) | 3 files | PDF/A-1b, PDF/A-2b, PDF/A-3b archival conformance | +| [`render/pdfa/`](samples/render/pdfa/) | 4 files | PDF/A-1b, PDF/A-2b, PDF/A-2u, PDF/A-3b archival conformance | | [`render/outline/`](samples/render/outline/) | scripts | PDF bookmarks — `--outline auto` + explicit tree | | [`render/math/`](samples/render/math/) | scripts | Math/technical symbols via `--font math` | | [`render/inspect-layout/`](samples/render/inspect-layout/) | scripts | `--inspect-layout` report + `--debug-layout` guides | -| [`render/chart/`](samples/render/chart/) | 2 files | Native vector charts (bar / line / pie / donut) | +| [`render/chart/`](samples/render/chart/) | 5 files | Native vector charts — bar/line/pie/donut plus (v1.4.0) stacked bars, area/scatter, dual axes, log & time axes | +| [`render/print/`](samples/render/print/) | 2 files | (v1.4.0) Print production — bleed/trim boxes + printer's marks, and viewer preferences (duplex, copies, …) | | [`merge/`](samples/merge/) | scripts | Concatenate PDFs (page-tree) | | [`split/`](samples/split/) | scripts | Split one PDF per-page or per-range | | [`extract/`](samples/extract/) | scripts | Pull selected pages into a new PDF | @@ -435,10 +533,12 @@ Ready-to-run examples are in [`samples/`](samples/), organized by feature catego | [`fill/`](samples/fill/) | scripts | Fill, flatten & export AcroForms | | [`encrypt/`](samples/encrypt/) | scripts | Encrypt / decrypt round-trip (AES-256, `--stream`) | | [`doctor/`](samples/doctor/) | scripts | Environment / capability preflight | +| [`metadata/`](samples/metadata/) | scripts | Update /Info + XMP metadata (incremental save) | +| [`compare/`](samples/compare/) | scripts | Diff two PDFs by text + structure | | [`annotate/`](samples/annotate/) | scripts | Attach markup annotations (incremental save) | | [`govern/`](samples/govern/) | scripts | AI-governance / HITL: rules, policy, verify-issue | -| [`sign/`](samples/sign/) | 7 scripts | Digital signature incl. native vs pure-JS crypto (Bash + PowerShell) | -| [`inspect/`](samples/inspect/) | 7 scripts | JSON & text inspection incl. `--annotations` (Bash + PowerShell) | +| [`sign/`](samples/sign/) | 9 script pairs | Digital signature incl. timestamp (B-T), LTV ladder, multi-signatures, native vs pure-JS crypto (Bash + PowerShell) | +| [`inspect/`](samples/inspect/) | 8 script pairs | JSON & text inspection incl. `--annotations` and `--signatures` (Bash + PowerShell) | | [`streaming/`](samples/streaming/) | 3 scripts | Streaming render (single-pass, page-by-page, true constant-memory) | **Render all samples at once:** @@ -453,14 +553,14 @@ See [`samples/README.md`](samples/README.md) for full descriptions, block type r ## Command Reference -The 17 commands are grouped by purpose (the global `pdfnative --help` shows the same grouping): +The 21 commands are grouped by purpose (the global `pdfnative --help` shows the same grouping): | Group | Commands | |-------|----------| -| **Create & edit** | [`render`](#pdfnative-render), [`fill`](#pdfnative-fill), [`annotate`](#pdfnative-annotate) | +| **Create & edit** | [`render`](#pdfnative-render), [`fill`](#pdfnative-fill), [`annotate`](#pdfnative-annotate), [`metadata`](#pdfnative-metadata) | | **Page tree** | [`merge`](#pdfnative-merge), [`split`](#pdfnative-split), [`extract`](#pdfnative-extract) | -| **Security** | [`sign`](#pdfnative-sign), [`verify`](#pdfnative-verify), [`encrypt`](#pdfnative-encrypt), [`decrypt`](#pdfnative-decrypt) | -| **Read & extract** | [`inspect`](#pdfnative-inspect), [`extract-text`](#pdfnative-extract-text) | +| **Security** | [`sign`](#pdfnative-sign), [`verify`](#pdfnative-verify), [`ltv`](#pdfnative-ltv), [`doc-timestamp`](#pdfnative-doc-timestamp), [`encrypt`](#pdfnative-encrypt), [`decrypt`](#pdfnative-decrypt) | +| **Read & extract** | [`inspect`](#pdfnative-inspect), [`extract-text`](#pdfnative-extract-text), [`compare`](#pdfnative-compare) | | **Automation & meta** | [`batch`](#pdfnative-batch), [`doctor`](#pdfnative-doctor), [`schema`](#pdfnative-schema), [`completion`](#pdfnative-completion), [`govern`](#pdfnative-govern) | ### `pdfnative render` @@ -472,6 +572,7 @@ The 17 commands are grouped by purpose (the global `pdfnative --help` shows the | `--stream` | false | Single-pass streaming output (`AsyncGenerator`); no TOC, no `{pages}` | | `--stream-page-by-page` | false | Stream at PDF object boundaries (TOC- and `{pages}`-compatible) | | `--stream-true` | false | True constant-memory streaming; parts freed as emitted; byte-identical; no TOC, no `{pages}` | +| `--chunk-size ` | `65536` | Chunk size for `--stream` / `--stream-true` (not `--stream-page-by-page`) | | `--variant ` | `document` | `document` (default) or `table` (selects `buildPDFBytes`) | | `--layout ` | — | Load a `Partial` (CLI flags override) | | `--page-size ` | from layout file or pdfnative default | Named (`a4`, `letter`, `legal`, `a3`, `tabloid`, `a5`) or `WxH` in points | @@ -479,6 +580,7 @@ The 17 commands are grouped by purpose (the global `pdfnative --help` shows the | `--compress` | false | Enable FlateDecode compression | | `--max-blocks ` | `100000` | Maximum document blocks before pdfnative aborts (large-report guard) | | `--tagged ` | none | PDF/A: `none`, `pdfa1b`, `pdfa2b`, `pdfa2u`, `pdfa3b` | +| `--strict` | false | Escalate PDF/A conformance diagnostics (`PDFA_NO_FONT_ENTRIES`, `PDFA_UNEMBEDDED_FORM_FONT`, `PDFA_DEVICE_CMYK_IMAGE`) into an error (exit 1, `E_CHECK_FAILED`) **before** any output byte; without it they are stderr warnings + a `diagnostics[]` array in the `--json` envelope | | `--conformance <1b\|2b\|3b>` | — | **Deprecated** — use `--tagged pdfa` | | `--watermark-text ` / `--watermark-image ` | — | Text or image watermark | | `--watermark-opacity <0-1>` / `--watermark-angle ` / `--watermark-color <#hex>` / `--watermark-font-size ` / `--watermark-position background\|foreground` | — | Watermark styling | @@ -497,25 +599,50 @@ The 17 commands are grouped by purpose (the global `pdfnative --help` shows the | `--inspect-layout` | false | Emit a `LayoutInspection` JSON report instead of a PDF (document variant only) | | `--debug-layout [margins,content,cells]` | — | Overlay layout debug guides on the rendered PDF (bare flag = all) | +**Document & layout JSON (v1.4.0, pdfnative 1.7.0)** — no new flags, richer JSON: +image blocks accept `src` (a path resolved relative to the `--input` JSON's directory) +as an alternative to `dataBase64`; `chart` blocks grow to **9 types** (`bar`, `barH`, +`stackedBar`, `stackedBarH`, `line`, `area`, `scatter`, `pie`, `donut`) with +`series[].xValues`, `series[].yAxis: "right"` + `axis2`, `axis.scale: "log"`, +`xAxis: category|linear|time`, `dataLabels`, `labelStride`, and `labelRotation`; the +`--layout` file gains `print` (bleed, `trimBox`, `bleedBox`, `artBox`, `cropBox`, +printer `marks`, `userUnit` 1–75000), `outputIntent` (ICC RGB) and `viewerPreferences` +(`duplex`, `pickTrayByPDFSize`, `printPageRange`, `numCopies`); `params.metadata` +accepts `author`/`subject`/`keywords`/`trapped`. Validate with `pdfnative schema render`. + See `samples/render/` for a working example of every category. ### `pdfnative sign` | Flag | Default | Description | |------|---------|-------------| -| `--input ` | — **(required)** | Path to the input PDF | +| `--input ` | stdin | Path to the input PDF | | `--output ` | stdout | Output signed PDF path | | `--key ` | `$PDFNATIVE_SIGN_KEY` | Path to PEM private key (env var takes precedence) | | `--cert ` | `$PDFNATIVE_SIGN_CERT` | Path to PEM certificate (env var takes precedence) | | `--cert-chain ` _(repeatable)_ | `$PDFNATIVE_SIGN_CHAIN` | Intermediate CA PEMs | | `--algorithm rsa-sha256\|ecdsa-sha256` | `rsa-sha256` | Signature algorithm (RSA or P-256 ECDSA) | +| `--digest sha256\|sha384\|sha512` | `sha256` | CMS digest algorithm (RSA only; `ecdsa-sha256` is sha256-only) | +| `--profile pkcs7\|pades` | `pkcs7` | `pades` = ETSI.CAdES.detached (PAdES B-B: ESS signing-certificate-v2, omits signing-time) | | `--reason ` | — | Reason for signing (PDF metadata) | | `--name ` | — | Signer name (PDF metadata) | | `--location ` | — | Signing location (PDF metadata) | | `--contact ` | — | Signer contact (PDF metadata) | | `--signing-time ` | now | Explicit signing timestamp | +| `--timestamp ` | — | Embed a verified **RFC 3161** timestamp token at signing time (PAdES B-T with `--profile pades`). **Opt-in network**, SSRF-guarded. TSA failure → `E_NETWORK`; malformed response → `E_PARSE`; never a silent fallback | +| `--timestamp-digest sha256\|sha384\|sha512` | `sha256` | Digest for the TSA message imprint | +| `--timestamp-nonce ` | random 8 bytes | Explicit TSA request nonce | +| `--allow-multiple` | false | Allow signing an already-signed PDF (appends a signature field); default stays idempotent single-signature (1.x behaviour) | +| `--field-name ` | auto | Signature form-field name | +| `--signature-rect "x1,y1,x2,y2"` | invisible | Visible signature widget rectangle (PDF points) | +| `--signature-page ` | `1` | 1-based page for the signature widget | +| `--placeholder-bytes ` | auto | Explicit `/Contents` placeholder size (overrides the estimate) | | `--pure-crypto` | false | Force pdfnative's pure-JS RSA/ECDSA math instead of the default native `node:crypto` (constant-time) provider | +Without `--timestamp` the command performs **no network I/O**, and `--dry-run` never +touches the network even when `--timestamp` is present. Under `--json`, a timestamped +signature adds `timestamp: { url, digest }` to the success envelope. + ### `pdfnative inspect` | Flag | Default | Description | @@ -524,13 +651,17 @@ See `samples/render/` for a working example of every category. | `--output ` | stdout | Output report path | | `--format json\|text` | `json` | Output format | | `--verbose` | false | Add trailer keys, catalog keys, object count, XMP | -| `--pages` | false | Add per-page metadata array | +| `--pages` | false | Add per-page metadata array (width/height/rotation + `cropBox`/`trimBox`/`bleedBox`/`artBox`/`userUnit` when present) | | `--annotations` | false | List markup + link annotations per page (page labels are reported automatically when present) | | `--form-fields` | false | List AcroForm fields (name, type, value, required/read-only, options) | | `--encryption` | false | Report the encryption scheme (`algorithm`, `revision`, `authenticatedAs`), or `null` | +| `--signatures` | false | Signature-field inventory: `fieldName`, `subFilter`, `byteRange`, `isDocTimestamp`, `isPlaceholder`, `sigObjNum`, `contentsLength` — never the signature bytes | | `--password ` | — | Password for an encrypted PDF (env: `PDFNATIVE_PASSWORD`) | | `--pdfua` | false | Add a PDF/UA (ISO 14289-1) structural validation report (`valid` + `errors` + `warnings`) | -| `--check pdfa\|signed\|encrypted\|pdfua` _(repeatable)_ | — | CI-friendly assertion; sets exit code (0 = pass, 1 = fail) | +| `--check pdfa\|signed\|encrypted\|pdfua\|"signatures>=N"` _(repeatable)_ | — | CI-friendly assertion; AND semantics; sets exit code (0 = pass, 1 = fail). `signatures>=N` counts real signatures (placeholders and `/DocTimeStamp` fields excluded) | + +v1.4.0 also fixes the top-level `signatures` and `formFields` counters, which previously +always reported `0`, and reports `metadata.trapped` when present. ### `pdfnative verify` @@ -543,16 +674,82 @@ See `samples/render/` for a working example of every category. | `--revocation offline\|online\|disabled` | `offline` | Revocation source: embedded `/DSS` only, also fetch online (SSRF-guarded), or skip | | `--revocation-policy soft-fail\|strict` | `soft-fail` | `strict` fails the signature on any non-`good` status; `soft-fail` only fails on explicit `revoked` | -**Scope (v1.0.0):** byte-range integrity (SHA-256), full CMS signature value -(RSA-PKCS#1 v1.5 SHA-256 + ECDSA-SHA256 over P-256), certificate chain + trust, -**RFC 3161 timestamp validation (PAdES-T)**, and **OCSP (RFC 6960) + CRL (RFC 5280) -revocation** — embedded from the PDF `/DSS` offline by default, with opt-in online -fetching through an SSRF-guarded HTTP client. Sign-side LTV (embedding timestamps / -DSS at signing time) is upstream-blocked in pdfnative — see [ROADMAP.md](ROADMAP.md) -and [SECURITY.md](SECURITY.md#network-access-revocation-checking). +**Scope:** byte-range integrity (SHA-256), full CMS signature value +(RSA-PKCS#1 v1.5 — SHA-256/384/512, reported as `rsa-sha256`/`rsa-sha384`/`rsa-sha512` — +plus ECDSA-SHA256 over P-256), certificate chain + trust, **RFC 3161 timestamp validation +(PAdES-T)**, and **OCSP (RFC 6960) + CRL (RFC 5280) revocation** — embedded from the PDF +`/DSS` offline by default, with opt-in online fetching through an SSRF-guarded HTTP client. +v1.4.0 additions: each signature also reports its `fieldName` and `isDocTimestamp`, and +**`/DocTimeStamp` revisions (PAdES B-LTA)** are validated as RFC 3161 timestamp tokens. +Sign-side LTV lives in [`sign --timestamp`](#pdfnative-sign), [`ltv`](#pdfnative-ltv) and +[`doc-timestamp`](#pdfnative-doc-timestamp). + +### `pdfnative ltv` + +PAdES **B-LT**: archive the certificates, OCSP responses and CRLs needed to validate the +document's signatures long after certificates expire, into the PDF's `/DSS` + `/VRI` +dictionaries (incremental save — existing signatures stay valid). + +```bash +pdfnative ltv collect --input signed.pdf --online [--output ltv.json] # fetch → replayable JSON +pdfnative ltv embed --input signed.pdf --data ltv.json [--output out.pdf] # 100 % offline +pdfnative ltv add --input signed.pdf --online [--output out.pdf] # collect + embed +``` + +`collect` **requires `--online`** (explicit network opt-in, SSRF-guarded, no redirects) +and emits a replayable JSON file (schema subject: `ltv-data`). `embed` **never** performs +network I/O — the collect/embed split supports air-gapped pipelines: collect on a +connected machine, embed offline. `add` does both in one pass. + +| Flag | Default | Description | +|------|---------|-------------| +| `--input ` | stdin | Signed source PDF | +| `--output ` | stdout | Output: JSON (`collect`) or PDF (`embed` / `add`) | +| `--online` | — **(required for `collect` / `add`)** | Explicit opt-in for network fetches (SSRF-guarded, no redirects) | +| `--prefer ocsp\|crl` | `ocsp` | Preferred revocation source | +| `--extra-cert ` _(repeatable)_ | — | Extra chain certificates (PEM) | +| `--data ` | — **(required for `embed`)** | Previously collected `ltv-data` JSON | +| `--timeout ` | `10000` | Network timeout | +| `--dry-run` | false | Validate inputs; no output, no network | + +**The canonical PAdES ladder:** + +```text +sign --timestamp --profile pades → B-T +ltv add --online → B-LT +doc-timestamp --url → B-LTA +ltv add --online → LTV for the doc-timestamp itself +``` + +### `pdfnative doc-timestamp` + +PAdES **B-LTA**: append a `/DocTimeStamp` signature field (SubFilter `/ETSI.RFC3161`, +ISO 32000-2 §12.8.5) covering every byte of the document, as an incremental revision — +earlier revisions stay **byte-identical**. Repeat periodically to renew LTA protection. + +| Flag | Default | Description | +|------|---------|-------------| +| `--input ` | stdin | Signed source PDF | +| `--output ` | stdout | Output PDF | +| `--url ` | — **(required)** | RFC 3161 TSA URL (explicit network opt-in; SSRF-guarded, no redirects) | +| `--digest sha256\|sha384\|sha512` | `sha256` | Timestamp message-imprint digest | +| `--field-name ` | `DocTimeStamp1` | Timestamp field name (auto-suffixed on collision) | +| `--placeholder-bytes ` | `12288` | `/Contents` placeholder size | +| `--nonce ` | random | Explicit TSA request nonce | +| `--timeout ` | `10000` | Network timeout | +| `--dry-run` | false | Validate inputs; no output, no network | + +TSA failures map to `E_NETWORK`; a malformed TSA response maps to `E_PARSE`. +`verify` validates `/DocTimeStamp` revisions as RFC 3161 tokens (`isDocTimestamp: true`). ### `pdfnative batch` +Two modes: **directory mode** (render every `*.json` in a directory, in parallel) and +**manifest mode** (v1.4.0 — run a declarative multi-command pipeline). The modes are +mutually exclusive. + +**Directory mode:** + | Flag | Default | Description | |------|---------|-------------| | `--input-dir ` | _required_ | Directory of `*.json` document definitions | @@ -563,6 +760,30 @@ and [SECURITY.md](SECURITY.md#network-access-revocation-checking). All other flags are forwarded to each `render`. Exit code 1 if any file fails. +**Manifest mode (v1.4.0):** + +| Flag | Default | Description | +|------|---------|-------------| +| `--manifest ` | — | Declarative pipeline (schema subject: `batch-manifest`): `{ "version": 1, "tasks": [ { "id", "command", "flags" } ] }` | +| `--allow-network` | false | **Required** for any network flag inside the manifest (`--timestamp`, `--url`, `--online`, `--revocation online`) — an untrusted manifest can never trigger network I/O on its own | +| `--continue-on-error` | false | Keep running after a failure; tasks depending (via `@`) on a failed task are skipped | +| `--format json\|text` | `text` | Summary format | +| `--dry-run` | false | Validate the manifest / inputs without executing | + +Tasks run **sequentially, fail-fast** by default. A flag value `"@"` references the +output of an **earlier** task; relative paths resolve against the manifest's directory. +14 commands are allowed inside a manifest — `render`, `sign`, `verify`, `inspect`, +`merge`, `split`, `extract`, `extract-text`, `fill`, `encrypt`, `decrypt`, `annotate`, +`metadata`, `doc-timestamp` (`ltv` and `compare` need positional arguments and are not +yet manifest-callable) — never `govern`, `schema`, `completion`, `doctor`, or `batch` +itself. Under `--json` the summary envelope adds `mode: "manifest"`, `skipped`, and a +per-task `tasks[]` array. Exit code 1 if any task fails. + +A manifest has the filesystem access of the user who invokes `batch` — the same trust +level as command-line flags; only network access is additionally gated behind +`--allow-network`. The manifest file is size-capped (50 MB) and bounded to 1 000 tasks, +and path values undergo the same anti-traversal check as direct CLI flags. + ### `pdfnative merge` Concatenate several PDFs into one (pdfnative 1.5.0 page-tree API). @@ -629,6 +850,30 @@ Extract reading-order Unicode text (pdfnative 1.6.0 `extractText`). No OCR — i | `--max-length ` | `16000000` | Hard cap on total characters (`0`/`none` disables) | | `--summary` / `--fields` / `--pretty` | — | Token-economy controls (json format) | +### `pdfnative compare` + +Diff two PDFs by extracted reading-order **text** and/or document **structure** — page +count, page/print boxes, metadata, form fields, annotations, encryption, signatures +(v1.4.0). Built for CI and agents: **identical documents exit 0**; any difference exits +**1** with the stable code `E_CHECK_FAILED` (the report is written to stdout first). +Visual/rasterised diffing is out of scope — pdfnative has no rasteriser. + +```bash +pdfnative compare a.pdf b.pdf --mode both --format json +``` + +| Flag | Default | Description | +|------|---------|-------------| +| _positional paths_ | — **(required)** | The two PDFs to compare, `a.pdf b.pdf` | +| `--mode text\|structure\|both` | `both` | What to compare | +| `--format text\|json` | `text` | Report format (stdout) | +| `--tolerance ` | `0` | Geometric tolerance in points for page/box sizes | +| `--ignore-whitespace` | false | Collapse runs of whitespace before the text diff | +| `--pages ` | _all_ | 1-based selector limiting the text diff (e.g. `1,3-5`) | +| `--password-a ` | — | Password for the first PDF | +| `--password-b ` | — | Password for the second PDF | +| `--pretty` | — | Force indented JSON even under `--json` | + ### `pdfnative fill` Fill, flatten, and/or export an existing AcroForm using an incremental save (existing signatures stay valid for their revision). Discover field names with `inspect --form-fields`, or dump the current values with `--export` for a read → edit → fill round-trip. @@ -680,7 +925,7 @@ Environment / capability preflight — for humans (onboarding) and agents (pre-f | `--format json\|text` | `text` | Output format (global `--json` also selects JSON) | | `--pretty` | — | Force indented JSON even under `--json` | -Checks: CLI version, Node version (≥ 20), Web Crypto (CSPRNG) availability, resolved `pdfnative` version, registered command count. +Checks: CLI version, Node version (≥ 22), Web Crypto (CSPRNG) availability, resolved `pdfnative` version, registered command count. ### `pdfnative annotate` @@ -692,12 +937,35 @@ bytes — and any existing signature — are preserved). | `--input ` | stdin | Source PDF | | `--output ` | stdout | Annotated PDF | | `--annotations ` | — **(required)** | JSON array (or `{ "annotations": [...] }`); each entry is a markup annotation plus a 1-based `page` | +| `--password ` | — | Password for an encrypted PDF (env: `PDFNATIVE_PASSWORD`) — appended objects are re-encrypted under the existing scheme (v1.4.0) | Annotation types: `text`, `highlight`, `underline`, `strikeout`, `squiggly`, `square`, `circle`, `line`, `freetext`. Every entry needs a `page` and a `rect` `[x1,y1,x2,y2]`; `line` also needs `start` and `end`. Only known fields are forwarded — unknown keys are dropped, so nothing can leak into the emitted dictionary. +### `pdfnative metadata` + +Update `/Info` + XMP metadata (v1.4.0). The update is an **incremental save**: the +original bytes are preserved as a prefix, so **existing digital signatures remain valid** +for their revision. The XMP packet is kept in sync (`xmp:ModifyDate`, `pdf:Keywords`, …). +Reading metadata stays in [`inspect`](#pdfnative-inspect). + +| Flag | Default | Description | +|------|---------|-------------| +| `--input ` | stdin | Source PDF | +| `--output ` | stdout | Updated PDF | +| `--title ` | — | Document title | +| `--author ` | — | Author | +| `--subject ` | — | Subject | +| `--keywords ` | — | Keywords (single string) | +| `--mod-date ` | now | Modification date (pass a fixed value for reproducible output) | +| `--from-json ` | — | JSON file `{ title?, author?, subject?, keywords?, modDate? }` — mutually exclusive with the per-field flags | +| `--password ` | — | Password for an encrypted PDF (env: `PDFNATIVE_PASSWORD`) | +| `--dry-run` | false | Validate inputs without writing output | + +At least one metadata field is required. + ### `pdfnative govern` Expose pdfnative's AI-governance / Human-in-the-Loop (HITL) contract. Agents act as @@ -740,10 +1008,14 @@ pdfnative schema extract-text # extract-text --format json output pdfnative schema fill # fill --data values input pdfnative schema form-export # fill --export output pdfnative schema govern-verify # govern verify-issue --json output +pdfnative schema metadata # metadata --from-json input (v1.4.0) +pdfnative schema ltv-data # replayable JSON emitted/consumed by ltv collect/embed (v1.4.0) +pdfnative schema compare # compare --format json output (v1.4.0) +pdfnative schema batch-manifest # batch --manifest pipeline file (v1.4.0) pdfnative schema status # the --json success envelope (write commands) pdfnative schema manifest # capability manifest: commands, flags, error codes pdfnative schema doctor # doctor --format json output -pdfnative schema list # list the available subjects +pdfnative schema list # list the available subjects (19 in v1.4.0) ``` The **manifest** (`schema manifest`) is a machine-readable capability document — every @@ -759,7 +1031,8 @@ discovery. A prose/LLM-facing version ships as [`llms.txt`](llms.txt) at the pac | `--quiet`, `-q` | Suppress progress output on stderr | | `--no-color` | Disable ANSI colour (also respects the `NO_COLOR` env var) | | `--json` | Agent mode: emit a JSON status/error envelope on stderr (data stays on stdout) | -| `--dry-run` | Validate inputs and exit without writing output (`render` / `sign` / `batch` / `merge` / `split` / `extract` / `annotate` / `fill` / `encrypt` / `decrypt`) | +| `--dry-run` | Validate inputs and exit without writing output (`render` / `sign` / `batch` / `merge` / `split` / `extract` / `annotate` / `fill` / `encrypt` / `decrypt` / `metadata` / `ltv` / `doc-timestamp`). Never performs network I/O, even when a network flag is present | +| `--max-inflate-size ` | Cap the decompressed size of any single PDF stream while parsing untrusted input (anti zip-bomb; default 100 MiB) — v1.4.0 | | `--version --json` | Machine-readable version output | ## Driving from AI agents @@ -772,9 +1045,11 @@ deterministically — no MCP server, no daemon, just the process contract: - Pass **`--json`** to get a single machine-readable envelope on stderr. On failure: `{ "ok": false, "command": "...", "error": { "code": "E_*", "message": "..." } }`. On success for the write commands (`render` / `sign` / `batch` / `merge` / `split` / - `extract` / `annotate` / `fill` / `encrypt` / `decrypt`): a `{ "ok": true, ... }` status line. + `extract` / `annotate` / `fill` / `encrypt` / `decrypt` / `metadata` / `ltv` / + `doc-timestamp`): a `{ "ok": true, ... }` status line. - Branch on the **stable error code** (`E_USAGE`, `E_INPUT`, `E_PARSE`, `E_IO`, `E_SIGN`, - `E_VERIFY_FAILED`, `E_CHECK_FAILED`, `E_POLICY`, `E_UNSUPPORTED`, `E_PASSWORD`, `E_RUNTIME`) + `E_VERIFY_FAILED`, `E_CHECK_FAILED`, `E_POLICY`, `E_UNSUPPORTED`, `E_PASSWORD`, + `E_NETWORK`, `E_RUNTIME`) rather than the message text. Numeric **exit codes** stay `0` (success), `1` (runtime), `2` (usage). - Use **`--dry-run`** to validate input without producing output. - Fetch a **`schema`** (or **`schema manifest`** / **`llms.txt`**) to discover and validate @@ -784,9 +1059,14 @@ See [AGENTS.md](AGENTS.md) and the [`samples/agent/`](samples/agent) scripts. ## Security -- **Offline by default** — no network access unless you pass `verify --revocation online`. - Online revocation requests pass an **SSRF guard** (scheme allow-list, private/loopback/ - link-local/CGNAT address blocking, no redirects, timeout + size caps). +- **Offline by default** — no network access unless you explicitly opt in with + `verify --revocation online`, `sign --timestamp `, `ltv collect|add --online`, + `doc-timestamp --url`, or `batch --allow-network` (which gates network flags inside a + manifest). Every request passes an **SSRF guard** (scheme allow-list, private/loopback/ + link-local/CGNAT address blocking, no redirects, timeout + size caps). A failed opt-in + network operation maps to the stable `E_NETWORK` code — never a silent fallback. +- **Anti zip-bomb cap** — the global `--max-inflate-size ` bounds the decompressed + size of any single PDF stream while parsing untrusted input (default 100 MiB). - **Signing keys are never logged** — not in error messages, not in debug output. - **Path traversal protection** — all file path arguments are validated against `../` sequences. - **JSON size cap** — input is capped at 50 MB before parsing to prevent memory exhaustion. diff --git a/ROADMAP.md b/ROADMAP.md index ff5cf5a..359e3f0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -146,42 +146,114 @@ This document outlines the planned development direction for pdfnative-cli. Prio (bundle-relative `package.json` path); version resolution is now bundle-safe. An empty env password no longer overrides an explicit `--password`. -### Next — Sign-side LTV (PAdES-T / LT / LTA), upstream-coordinated - -Sign-side LTV is **PDF-writing logic that belongs in pdfnative**; the CLI exposes the -surface and will light it up once the upstream primitives ship. - -- [ ] **`sign --timestamp `** — embed an RFC 3161 timestamp token into the CMS - at signing time. Flag is reserved (errors clearly today); blocked on pdfnative - timestamp-embedding support. -- [ ] **PAdES-B-LT / B-LTA** — emit `/DSS` dictionaries and document timestamps when - signing. Blocked on pdfnative DSS-writing primitives. -- [ ] **OCSP / CRL stapling at signing time** — collect and embed revocation data into - the signed PDF for archival. +### v1.4.0 — PAdES B-T/B-LT/B-LTA, compare, metadata & manifest pipelines _(released 2026-08-26)_ +- [x] **`pdfnative` bumped** to `^1.7.0` (was `^1.6.0`); **Node ≥ 22** (Node 20 is EOL). +- [x] **`sign --timestamp ` (PAdES B-T)** — the formerly reserved flag now embeds a + verified RFC 3161 timestamp token in the CMS unsigned attributes at signing time + (`signPdfBytesWithTimestamp` + a CLI-injected, SSRF-guarded TSA provider). Plus + `--timestamp-digest sha256|sha384|sha512` and `--timestamp-nonce `. +- [x] **`sign` — profiles, digests, placement & multiple signatures** — `--profile pkcs7|pades` + (ETSI.CAdES.detached, PAdES B-B), `--digest sha256|sha384|sha512` (RSA), and + `--allow-multiple` / `--field-name` / `--signature-rect` / `--signature-page` / + `--placeholder-bytes` for appending additional signature fields. +- [x] **`ltv` command (PAdES B-LT)** — `collect` (fetch OCSP/CRL, **requires `--online`**) / + `embed` (offline, air-gap friendly) / `add` (both in one pass); writes `/DSS` + `/VRI` via + `collectValidationInfo` / `embedValidationInfo` / `addValidationInfo`. +- [x] **`doc-timestamp` command (PAdES B-LTA)** — appends a `/DocTimeStamp` signature field + (SubFilter `/ETSI.RFC3161`) as an incremental revision via `addDocumentTimestamp`; + `--url` is the explicit network opt-in. +- [x] **`verify` — LTV-era upgrades** — SHA-384/512 CMS digests, per-signature `fieldName`, + and `/DocTimeStamp` revisions validated as RFC 3161 tokens (`isDocTimestamp: true`). +- [x] **`metadata` command** — update `/Info` + XMP (title/author/subject/keywords/modDate) + with an incremental save that keeps existing signatures valid (`modifier.updateMetadata`). +- [x] **`compare` command** — text + structural diff of two PDFs (`--mode`, `--tolerance`, + `--ignore-whitespace`, `--pages`, per-side passwords); identical → exit 0, differences → + exit 1 with stable `E_CHECK_FAILED`. Visual/pixel diffing stays out of scope (no rasteriser). +- [x] **`batch --manifest tasks.json`** — declarative multi-command pipelines (`@id` output + references, a 14-command whitelist, `--allow-network` gate, `--continue-on-error`); + Future Consideration, now shipped. +- [x] **`render` — print production & charts v2** — `layout.print` (bleed/trim/art/crop boxes, + printer's marks, `userUnit`), `outputIntent` (ICC RGB), `viewerPreferences` (duplex, + `pickTrayByPDFSize`, `printPageRange`, `numCopies`), `params.metadata.trapped`; charts grow + to 9 types (`stackedBar`, `stackedBarH`, `area`, `scatter`) with `axis2`, `xAxis` + `category|linear|time`, log scale, `dataLabels`, `labelStride`/`labelRotation`. +- [x] **`render` — PDF/A diagnostics + images** — `--strict` escalates conformance diagnostics + to a pre-output error (otherwise stderr warnings + a `diagnostics[]` array in the `--json` + envelope); `image` blocks are now JSON-usable via `src` (path) / `dataBase64`; `--chunk-size` + for the streaming modes. +- [x] **`inspect` — signature inventory & print boxes** — `--signatures` (via `listSignatures`), + `--check "signatures>=N"`, per-page Bleed/Trim/Art boxes + `userUnit`, `metadata.trapped`; + **fix**: the per-page `signatures`/`formFields` counters always reported 0. +- [x] **`annotate --password`** — annotate encrypted PDFs (appended objects encrypted under the + existing scheme). +- [x] **Global `--max-inflate-size`** — anti-zip-bomb cap on any single decompressed PDF stream + (default 100 MiB) via `setMaxInflateOutputSize`. +- [x] **Page-box preservation** — `merge`/`split`/`extract` now preserve Bleed/Trim/Art boxes + and `/UserUnit` (pdfnative 1.7.0). +- [x] **Agent surface** — new stable `E_NETWORK` code; schema subjects `metadata`, `ltv-data`, + `compare`, `batch-manifest` (19 total). +- [x] **Offline mock-PKI test infrastructure** — `tests/helpers/mock-pki.ts` runs a real + RFC 3161 TSA + OCSP/CRL responder in-process, so the network paths are tested without + touching the network (600 tests). +- [x] **Blocking veraPDF PDF/A gate** — `npm run validate:pdfa` over a 12-file manifested + corpus (10 positives + 2 negative canaries with expected ISO 19005 clauses), blocking in + CI (`verapdf.yml`, pinned installer with verified SHA-256) and again before every + `npm publish`. ## Future Considerations Feasibility is called out honestly: some ideas need pdfnative to expose a primitive first (the CLI stays a thin dispatch layer and never re-implements engine logic). -- **`batch --manifest tasks.json`** — turn `batch` into a file-driven task orchestrator: a JSON - manifest of steps (e.g. `render → sign → encrypt`), each an existing command, run in order - with a JSON summary and stable exit codes. **Feasible today** (composes existing commands, no - new pdfnative primitive) — a strong agent-automation candidate. -- **`compare a.pdf b.pdf`** — diff two PDFs for CI / regression testing, with `--format json`, - a stable `E_CHECK_FAILED` exit, and `--tolerance`. **Text / structural** diff is feasible now - (`extractText` + object / metadata comparison). A **visual** (pixel) diff is **blocked**: - pdfnative is a generator/parser with **no rasteriser**, so rendering pages to images is out of - scope until an upstream raster primitive exists. +- **`compare` — visual (pixel) diff** — the text / structural diff **shipped in v1.4.0**; a + **visual** diff remains **blocked**: pdfnative is a generator/parser with **no rasteriser**, + so rendering pages to images is out of scope until an upstream raster primitive exists. - **`optimize`** — shrink PDFs for web/archival: image re-compression/resampling, unused-object GC, and linearisation ("Fast Web View"). **Blocked** — pdfnative does not yet expose the low-level optimisation/linearisation primitives this would wrap. -- **`modify` standalone command** — in-place object edits that preserve signatures/forms — - awaits the matching pdfnative primitives. +- **`modify` standalone command** — **partially delivered** in v1.4.0 via `metadata` + (incremental `/Info` + XMP edits that keep signatures valid). Arbitrary in-place object + edits remain blocked on the matching pdfnative primitives. +- **`render --font-file `** — custom (non-bundled) fonts via pdfnative's + `validateFontData` / `parseFontData`. Feasible upstream; needs a security posture first + (parsing untrusted font binaries from the CLI surface). +- **`link` annotations on existing PDFs** — `annotate` could gain the `link` type via + pdfnative's `buildLinkAnnotation` (today it covers markup types only). +- **`doctor` — language-pack enumeration** — list the registered/bundled font packs via + `getRegisteredLangs` in the capability report. +- **Dedicated TSA timeout flags** — `sign --timestamp` / `doc-timestamp` / `ltv` share the + guarded 10 s default (`--timeout` exists on `ltv` / `doc-timestamp`); a dedicated + per-TSA-request timeout flag on `sign` is a candidate refinement. - **Category help commands** (`pdfnative page --help`, `pdfnative security --help`) — the global `--help` already **groups** commands by category (Create & edit / Page tree / Security / Read & extract / Automation & meta); dedicated category dispatch commands are deferred (extra surface + category-vs-command ambiguity). - **Additional shell integrations** — PowerShell completion ✅ shipped in v1.3.0. **man pages** remain (deferred: ongoing maintenance cost vs. `--help`/completions already covering usage). +- **Positional arguments in manifest tasks** — `batch --manifest` tasks carry only a flat flag + map today, so `ltv` (subcommand positional) and `compare` (two positional PDF paths) are + excluded from the manifest whitelist. Supporting positionals would reintroduce both. +- **`verify` — weak-digest note for RFC 3161 timestamps** — emit a "weak digest" note when a + timestamp token's `messageImprint` uses SHA-1, and refuse it under `--strict`. +- **fetch-guard — additional blocked ranges** — also block the benchmarking range + 198.18.0.0/15, the documentation range 192.0.2.0/24 (TEST-NET-1), and the NAT64 prefix + 64:ff9b::/96 in the SSRF guard. +- **JSON size cap on `--layout`** — apply `assertJsonSizeLimit` to the `--layout` file the way + the 50 MB cap already guards the document input. +- **`inspect` — ISO 8601 date normalisation** — `/Info` dates are emitted as the raw PDF date + string (e.g. `D:20260427120000+00'00'`); an opt-in flag could normalise them to ISO 8601. +- **Global flags before the command name** — `pdfnative --json …` currently swallows the + command name; the parser could accept global flags placed in front (`llms.txt` documents the + workaround: place `--json` after the sub-command). +- **CHANGELOG compare-link retrofit** — add `[x.y.z]: …/compare/…` reference links across the + full historical release list. +- **`--variant table` cannot embed fonts** — the `--lang` → `fontEntries` merge only exists on + the document path, and `PdfParams.fontEntries` needs binary data JSON cannot carry, so a + table-variant render under a PDF/A claim is structurally non-conformant (it serves as a + negative canary in the veraPDF corpus). Needs a CLI-side embedding path for the table + variant. +- **veraPDF setup as a composite action** — the pinned installer block is duplicated between + `verapdf.yml` and `publish.yml`; extract `.github/actions/setup-verapdf` (validate with a + real CI run) so the URL/SHA-256 bump happens in one place. Also revisit the failed-rule + display regex (attribute-order-dependent, cosmetic) at the next veraPDF version bump. diff --git a/SECURITY.md b/SECURITY.md index cf65a1c..7b21e3c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -14,25 +14,28 @@ We will acknowledge receipt within 48 hours and aim to provide a fix within 7 da | Version | Supported | |---------|-----------| +| 1.4.x | ✅ | +| 1.3.x | ✅ | | 1.2.x | ✅ | -| 1.1.x | ✅ | -| 1.0.x | ✅ | -| < 1.0 | ❌ | +| < 1.2 | ❌ | ## Security Model pdfnative-cli is a thin dispatch layer over the [`pdfnative`](https://github.com/Nizoka/pdfnative) library. It introduces zero additional runtime dependencies. All PDF cryptographic operations are performed inside `pdfnative` — see the [pdfnative security policy](https://github.com/Nizoka/pdfnative/blob/main/SECURITY.md) for the full cryptographic implementation notes (RSA, ECDSA, AES). -The CLI exposes six commands (`render`, `sign`, `inspect`, `verify`, `batch`, `completion`), plus a `schema` helper. The `sign` and `verify` commands handle key material and certificate chain loading; security invariants for each are described below. +The CLI exposes 21 commands (run `pdfnative --help` or `pdfnative schema manifest` +for the authoritative list). The `sign`, `verify`, `ltv` and `doc-timestamp` +commands handle key material, certificate chains and trusted-timestamp tokens; +security invariants for each are described below. ### Agent Mode (`--json`, `--dry-run`) The agent-native contract is a **pure local presentation/validation layer** and adds **no network surface**: - `--json` only changes how diagnostics are formatted on **stderr** (a machine-readable envelope). It never opens sockets, never alters what is written to stdout, and never relaxes any security check. -- `--dry-run` validates inputs and short-circuits **before** producing or writing output. For `sign` it stops after credentials are parsed and the PDF is prepared, before any signature value is computed — and still never logs key material. -- Stable `E_*` error codes carry only a failure class and a redacted message; internal byte offsets, parser state, and key bytes are never exposed (the `sign` failure message stays the fixed `Failed to sign PDF.`). -- The CLI remains **offline by default** in every mode; only `verify --revocation online` performs network requests, and only through the SSRF guard. +- `--dry-run` validates inputs and short-circuits **before** producing or writing output — and **never performs network I/O**, even when a network flag (`--timestamp`, `--url`, `--online`) is present. For `sign` it stops after credentials are parsed and the PDF is prepared, before any signature value is computed — and still never logs key material. +- Stable `E_*` error codes carry only a failure class and a redacted message; internal byte offsets, parser state, and key bytes are never exposed (the `sign` failure message stays the fixed `Failed to sign PDF.`). TSA / OCSP / CRL response bodies are never echoed into CLI output (`E_NETWORK` messages are generic). +- The CLI remains **offline by default** in every mode; network I/O happens only behind the explicit opt-in flags listed under *Network Access* below, and only through the SSRF guard. ### Signing Key Handling @@ -44,28 +47,43 @@ The agent-native contract is a **pure local presentation/validation layer** and ### Input Validation -- All file path arguments (`--input`, `--output`, `--output-dir`, `--key`, `--cert`, `--cert-chain`, `--layout`, `--attachment`, `--watermark-image`, `--outline`, `--annotations`, `--trust`, and the positional source paths of `merge`) are validated against path traversal (`../`) sequences before any filesystem access. -- JSON input size is capped at **50 MB** before `JSON.parse` to prevent memory exhaustion (this also covers the `annotate --annotations` spec and `govern verify-issue` drafts). +- All file path arguments (`--input`, `--output`, `--output-dir`, `--key`, `--cert`, `--cert-chain`, `--layout`, `--attachment`, `--watermark-image`, `--outline`, `--annotations`, `--trust`, `--data`, `--from-json`, `--manifest`, the positional source paths of `merge` and `compare`, and every path-carrying value inside a `batch --manifest` file) are validated against path traversal (`../`) sequences before any filesystem access. +- JSON input size is capped at **50 MB** before `JSON.parse` to prevent memory exhaustion (this also covers the `annotate --annotations` spec, `govern verify-issue` drafts, `ltv --data` files and `batch --manifest` files; manifests are additionally capped at 1 000 tasks). +- A **manifest has the filesystem access of the user who invokes `batch`** — the same trust level as flags typed on the command line. Only *network* access is additionally gated: any network-reaching flag inside a manifest requires `--allow-network` on the invocation itself, so a manifest obtained from elsewhere can never open a socket on its own. +- The global `--max-inflate-size ` flag caps the decompressed size of any single PDF stream while parsing untrusted input (anti zip-bomb; engine default 100 MiB). - `merge` / `split` / `extract` enforce an optional `--max-output-size` cap and bound the number of source PDFs; `extract` / `annotate` bounds-check every page reference against the document before writing. - `annotate` re-keys only the annotation fields pdfnative's builders understand — the raw JSON is never spread into the emitted dictionary, so unknown keys cannot be injected. - `inspect` JSON output sanitizes all values — no raw binary blobs are emitted in default mode. ### Code Safety -- No `eval()`, `Function()`, or dynamic code execution. -- **Offline by default** — no command opens a socket unless you explicitly pass - `verify --revocation online`. The `govern` command (AI-governance / HITL) is fully - offline: it never contacts GitHub or the network, and `govern verify-issue` is a pure - local validator. See *Network Access* below. +- No `eval()`, `Function()`, or dynamic code execution (`batch --manifest` dispatches + only to a fixed whitelist of CLI command modules — never to arbitrary code). +- **Offline by default** — no command opens a socket unless you pass one of the + explicit opt-in flags listed below. The `govern` command (AI-governance / HITL) is + fully offline: it never contacts GitHub or the network, and `govern verify-issue` + is a pure local validator. See *Network Access* below. - NPM provenance — signed builds via GitHub Actions OIDC. -### Network Access & Revocation Checking +### Network Access (opt-in only) -The CLI is **offline by default**. The only command that can make a network -request is `verify`, and only when you opt in with `--revocation online`. +The CLI is **offline by default**. Exactly four flags can cause a network request, +each naming the operation it enables: -When online revocation is enabled, every OCSP (AIA) and CRL (CDP) request passes -through an SSRF guard (`src/utils/fetch-guard.ts`) that enforces: +| Opt-in | Command | What is fetched | +|--------|---------|-----------------| +| `--revocation online` | `verify` | OCSP (AIA) + CRL (CDP) revocation data | +| `--timestamp ` | `sign` | An RFC 3161 timestamp token from the named TSA | +| `--url ` | `doc-timestamp` | An RFC 3161 token for the `/DocTimeStamp` revision | +| `--online` | `ltv collect` / `ltv add` | OCSP + CRL validation data to archive in `/DSS` | + +Inside a `batch --manifest` pipeline these flags are additionally refused unless the +`batch` invocation itself carries `--allow-network`. `ltv embed` is network-free by +design (air-gapped embedding of pre-collected data), and `--dry-run` never opens a +socket in any command. + +Every request passes through the same SSRF guard (`src/utils/fetch-guard.ts`) that +enforces: - an **http/https-only** scheme allow-list; - **DNS resolution followed by address vetting** — requests to private (RFC 1918), @@ -87,32 +105,52 @@ status, never a `good` one. The `verify` command verifies, with no network access by default: -- **Byte-range integrity** — SHA-256 of the covered bytes vs the CMS `messageDigest`. -- **CMS signature value** — RSA-PKCS#1 v1.5 SHA-256 and ECDSA-SHA256 (P-256) over - the re-encoded `signedAttrs`. +- **Byte-range integrity** — the signer's declared digest (SHA-256, SHA-384 or + SHA-512) of the covered bytes vs the CMS `messageDigest`. +- **CMS signature value** — RSA-PKCS#1 v1.5 with SHA-256/384/512, and ECDSA-SHA256 + (P-256), over the re-encoded `signedAttrs`. ECDSA with SHA-384/512 is detected and + labelled but never reported valid (verification is P-256 + SHA-256 only). - **Certificate chain & trust** — chain construction and evaluation against `--trust` roots (or self-signed acceptance when no roots are supplied). - **RFC 3161 timestamp (PAdES-T)** — the TSA SignerInfo signature, the TSTInfo eContent digest, and the `messageImprint` binding to the document signature are validated, and the TSA chain is built/trust-evaluated. Reported as `timestampValid`. +- **`/DocTimeStamp` revisions (PAdES B-LTA)** — each document timestamp's token is + parsed, its `messageImprint` is checked against the covered byte range, and the TSA + token signature is verified; reported with `isDocTimestamp: true`. - **OCSP (RFC 6960) + CRL (RFC 5280) revocation** — embedded `/DSS` data (offline) and, with `--revocation online`, AIA/CDP fetches via the SSRF-guarded client. +Sign-side LTV is available since v1.4.0: `sign --timestamp` (PAdES B-T), +`ltv collect|embed|add` (B-LT, `/DSS` + `/VRI`) and `doc-timestamp` (B-LTA). The +engine (pdfnative 1.7.0) verifies every TSA token before embedding it and never +opens a socket itself — the CLI injects the SSRF-guarded transport. + **Out of scope** (do not rely on for legal / regulatory non-repudiation): -- **Sign-side LTV** — embedding timestamps, DSS dictionaries, VRI or - document-timestamp chains *at signing time* is upstream-blocked in pdfnative; the - `sign --timestamp` flag is reserved and currently errors. Tracked in - [ROADMAP.md](./ROADMAP.md). -- **Full PAdES-B-LTA archival validation** — document-timestamp chain evaluation over - time is not performed. +- **Full PAdES-B-LTA archival validation** — evaluation of a document-timestamp + *chain over time* (renewal policy, algorithm rollover assessment) is not performed; + each timestamp is validated individually. +- **TSA certificate revocation** — the revocation status of the TSA's own + certificate is not checked. ### Cryptographic algorithm usage All signature-relevant hashing and verification uses SHA-256 or stronger (see the -scope above). **SHA-1 appears in exactly one place: the OCSP `CertID`** built by -`buildOcspRequest` and matched in `ocspCertIdMatches` -([src/utils/revocation.ts](./src/utils/revocation.ts)). This is **intentional and safe**: +scope above). SHA-1 appears in two deliberate places: + +1. **Verification of legacy timestamp imprints** — an existing RFC 3161 token whose + `messageImprint` was computed with SHA-1 is still *checked* (the digest named by + the token's own `hashAlgorithm` is used for the comparison). This affects + verification of third-party documents only; the CLI always *requests* SHA-256+ + imprints when it timestamps (`--timestamp-digest` / `--digest`, default sha256), + and the token's TSA signature itself must verify with SHA-256+. +2. **The OCSP `CertID`** built by `buildOcspRequest` and matched in + `ocspCertIdMatches` ([src/utils/revocation.ts](./src/utils/revocation.ts)). + (SHA-1 of a signature's `/Contents` is also used as the — non-cryptographic — + `/VRI` dictionary key, as required by ISO 32000-2.) + +The `CertID` usage is **intentional and safe**: - RFC 6960 §B.1 defines **SHA-1 as the default `CertID` hash algorithm**, and it is the only one reliably indexed by deployed OCSP responders; using SHA-256 would make diff --git a/docs/KNOWLEDGE_BASE.md b/docs/KNOWLEDGE_BASE.md index bf2f3e8..d7e4d4e 100644 --- a/docs/KNOWLEDGE_BASE.md +++ b/docs/KNOWLEDGE_BASE.md @@ -8,24 +8,25 @@ ## 1. Context **What is pdfnative-cli?** -The official command-line interface for [`pdfnative`](https://github.com/Nizoka/pdfnative) — a zero-dependency, ISO 32000-1 compliant PDF generation library. The CLI exposes 17 commands, grouped by purpose (the global `--help` shows the same grouping): +The official command-line interface for [`pdfnative`](https://github.com/Nizoka/pdfnative) — a zero-dependency, ISO 32000-1 compliant PDF generation library. The CLI exposes 21 commands, grouped by purpose (the global `--help` shows the same grouping): | Group | Commands | |-------|----------| -| Create & edit | `render`, `fill`, `annotate` | +| Create & edit | `render`, `fill`, `annotate`, `metadata` | | Page tree | `merge`, `split`, `extract` | -| Security | `sign`, `verify`, `encrypt`, `decrypt` | -| Read & extract | `inspect`, `extract-text` | +| Security | `sign`, `verify`, `ltv`, `doc-timestamp`, `encrypt`, `decrypt` | +| Read & extract | `inspect`, `extract-text`, `compare` | | Automation & meta | `batch`, `doctor`, `schema`, `completion`, `govern` | `schema` (self-validation + capability manifest) and `doctor` (capability pre-flight) support agent automation. **Philosophy:** -- Zero extra runtime dependencies — `pdfnative` is the *only* dependency. +- Zero extra runtime dependencies — `pdfnative` (`^1.7.0`) is the *only* dependency. - Pure dispatch layer — no PDF logic lives in the CLI itself. - Composable — every command reads from stdin and writes to stdout by default. +- Offline by default — network I/O only on explicit opt-in flags (`sign --timestamp`, `doc-timestamp --url`, `ltv --online`, `verify --revocation online`, `batch --allow-network`), always through the SSRF guard. -**Targets:** Node.js ≥ 20, Bun, Deno (via `node dist/cli.cjs`) +**Targets:** Node.js ≥ 22 (Node 20 is EOL), Bun, Deno (via `node dist/cli.cjs`) **Repository:** https://github.com/Nizoka/pdfnative-cli **npm:** https://www.npmjs.com/package/pdfnative-cli @@ -51,9 +52,13 @@ src/ │ ├── encrypt.ts # PDF → page-tree re-encryption (AES-128/256) │ ├── decrypt.ts # Encrypted PDF + --password → plaintext copy │ ├── annotate.ts # PDF + --annotations → createModifier + buildAnnotationBody → incremental save +│ ├── metadata.ts # (v1.4.0) /Info + XMP update → modifier.updateMetadata → incremental save +│ ├── ltv.ts # (v1.4.0) PAdES B-LT: collect | embed | add — /DSS + /VRI validation data +│ ├── docTimestamp.ts # (v1.4.0) PAdES B-LTA: /DocTimeStamp via addDocumentTimestamp (--url opt-in) +│ ├── compare.ts # (v1.4.0) Text + structural diff of two PDFs (E_CHECK_FAILED on difference) │ ├── govern.ts # AI-governance / HITL: rules | policy | verify-issue │ ├── schema.ts # Versioned JSON Schemas (Draft 2020-12) + capability manifest -│ ├── batch.ts # Directory of JSON → parallel render → per-file summary +│ ├── batch.ts # Directory of JSON → parallel render, or --manifest task pipeline (v1.4.0) │ ├── completion.ts # bash/zsh/fish/powershell completion scripts │ └── doctor.ts # Environment / capability preflight (text | --json) ├── utils/ @@ -73,10 +78,20 @@ src/ │ ├── cert-chain.ts # X.509 chain construction + trust evaluation │ ├── timestamp-verify.ts # RFC 3161 timestamp validation (PAdES-T) │ ├── revocation.ts # OCSP (RFC 6960) + CRL (RFC 5280), DSS + online -│ ├── fetch-guard.ts # SSRF-guarded HTTP(S) client (opt-in online revocation) -│ └── error.ts # CliError class + die() + deprecate() helpers (incl. E_POLICY) +│ ├── fetch-guard.ts # SSRF-guarded HTTP(S) client (every opt-in network path goes through it) +│ ├── tsa.ts # (v1.4.0) RFC 3161 TSA provider (TimestampProvider injected into pdfnative) +│ ├── ltv-provider.ts # (v1.4.0) OCSP/CRL RevocationProvider for `ltv` (AIA / CDP fetch via fetch-guard) +│ ├── manifest.ts # (v1.4.0) batch --manifest: parse/validate tasks.json, @id refs, network gate +│ ├── agent.ts # --json envelopes, stable-code default messages, dry-run helpers +│ ├── projection.ts # Token economy: --summary / --fields / compact JSON +│ └── error.ts # CliError class + die() + deprecate() helpers (stable E_* codes) └── core-bridge/ └── index.ts # Selective re-exports from pdfnative + +tests/helpers/ +├── der.ts # (v1.4.0) DER building blocks shared by the mock PKI +└── mock-pki.ts # (v1.4.0) Offline mock PKI: real in-process RFC 3161 TSA + + # OCSP/CRL responders — network code paths tested with zero network ``` ### Data Flow @@ -219,6 +234,8 @@ pdfnative render [--input ] [--output ] [--stream|--stream-p | `--outline` | `auto`\|`` | — | PDF bookmarks: `auto` from headings, or an explicit `OutlineItem[]` tree | | `--inspect-layout` | boolean | false | Emit a `LayoutInspection` JSON report instead of a PDF (document variant only) | | `--debug-layout` | `[margins,content,cells]` | — | Overlay layout debug guides on the PDF (bare flag = all) | +| `--strict` | boolean | false | (v1.4.0) Escalate PDF/A conformance diagnostics (`PDFA_NO_FONT_ENTRIES`, `PDFA_UNEMBEDDED_FORM_FONT`, `PDFA_DEVICE_CMYK_IMAGE`) into an error **before any output byte** (exit 1, `E_CHECK_FAILED`). Without it they are stderr warnings, plus a `diagnostics[]` array in the `--json` envelope | +| `--chunk-size` | bytes | 65536 | (v1.4.0) Chunk size for `--stream` / `--stream-true` (not `--stream-page-by-page`) | | `--conformance` | `1b`\|`2b`\|`3b` | — | **Deprecated** — use `--tagged pdfa` | **JSON schema:** Full [`DocumentParams`](https://github.com/Nizoka/pdfnative) — same object passed to `buildDocumentPDFBytes()`. @@ -260,16 +277,21 @@ pdfnative render [--input ] [--output ] [--stream|--stream-p | `link` | `text`, `url` | `fontSize`, `color` | [01-resource-directory.json](../samples/render/link/01-resource-directory.json) | | `toc` | — | `title`, `maxLevel` | [01-document-with-toc.json](../samples/render/toc/01-document-with-toc.json) | | `formField` | `fieldType` (`text`\|`textarea`\|`checkbox`\|`radio`\|`select`), `name` | `label`, `value`, `placeholder`, `options`, `readOnly`, `required`, `maxLength`, `width` | [01-contact-form.json](../samples/render/form/01-contact-form.json) | -| `chart` | `chartType` (`bar`\|`barH`\|`line`\|`pie`\|`donut`), `series` (`{ label, values[] }[]`) | `title`, `categories`, `legend`, `width`, `height`, `axis` | [01-bar-chart.json](../samples/render/chart/01-bar-chart.json) | +| `chart` | `chartType` (`bar`\|`barH`\|`stackedBar`\|`stackedBarH`\|`line`\|`area`\|`scatter`\|`pie`\|`donut`), `series` (`{ label, values[], xValues?, yAxis? }[]`) | `title`, `categories`, `legend`, `width`, `height`, `axis` (incl. `scale: "log"`), `axis2`, `xAxis` (`category`\|`linear`\|`time`), `dataLabels`, `labelStride`, `labelRotation`, `markers` | [01-bar-chart.json](../samples/render/chart/01-bar-chart.json), [03-stacked-bars.json](../samples/render/chart/03-stacked-bars.json), [04-area-scatter.json](../samples/render/chart/04-area-scatter.json), [05-time-axis.json](../samples/render/chart/05-time-axis.json) | +| `image` | `src` (path) **or** `dataBase64` (base64 JPEG/PNG) | `width`, `height`, `align`, `alt` | — | +| `svg` | `data` (SVG path `d` string or SVG markup) | `width`, `height`, `align`, `viewBox`, `fill`, `stroke`, `strokeWidth`, `alt` | — | | `spacer` | `height` (points) | — | any sample | | `pageBreak` | — | — | [03-all-blocks.json](../samples/render/document/03-all-blocks.json) | -> `image` and `svg` block types require `Uint8Array` payloads and are only usable via the `pdfnative` Node.js API — not through the CLI JSON interface. +> **`svg` is fully JSON-usable** (pdfnative ≥ 1.5.0): `SvgBlock.data` is a **string** (an SVG path `d` attribute, or SVG markup). **`image` is JSON-usable since v1.4.0**: give it `src` (a JPEG/PNG path, resolved relative to the `--input` JSON's directory) or `dataBase64` (inline base64) — the CLI resolves either to the `data: Uint8Array` pdfnative's `ImageBlock` requires (a raw JSON number array `data` also works). `src` + `dataBase64` together is an `E_INPUT` error. + +**Print production & viewer preferences (v1.4.0, `--layout` JSON):** +- `layout.print` — `bleed` shorthand or explicit `trimBox` / `bleedBox` / `artBox` / `cropBox` page boxes, `marks: true` (crop + registration marks), `userUnit` (large-format pages). +- `layout.outputIntent` — ICC RGB output intent (PDF/A-style colour characterisation). +- `layout.viewerPreferences` — print-dialog defaults: `duplex`, `pickTrayByPDFSize`, `printPageRange`, `numCopies`. +- `params.metadata.trapped` — `/Trapped` flag (`True` \| `False` \| `Unknown`). -**Experimental / Not yet exposed in CLI:** -- `watermark` — Use `pdfnative` Node.js API directly: `buildDocumentPDFBytes(params, { watermark: {...} })` -- Custom header/footer templates — Use `headerTemplate` and `footerTemplate` in layout options (Node.js API) -- Encryption — Use `encryption` option in layout (Node.js API) +See `pdfnative schema render` for the exact shapes, and [`samples/render/print/`](../samples/render/print/). See [`samples/`](../samples/) for complete working examples of every supported block type. @@ -304,7 +326,7 @@ pdfnative render --input multi.json --font ja --font ar --output multi.pdf Node.js API directly from a thin wrapper script: ```js -// myscript.js (Node.js >= 20, ESM) +// myscript.js (Node.js >= 22, ESM) import { registerFonts, loadFontData, buildDocumentPDFBytes } from 'pdfnative'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; @@ -352,20 +374,37 @@ See [`samples/render/multilang/`](../samples/render/multilang/) for complete wor ### `sign` -**Purpose:** Apply a CMS/PKCS#7 digital signature to an existing PDF. +**Purpose:** Apply a CMS/PKCS#7 or PAdES digital signature to an existing PDF — optionally with an RFC 3161 trusted timestamp (PAdES B-T) and/or as an additional signature on an already-signed document. ```bash pdfnative sign --input [--output ] [--key ] [--cert ] +pdfnative sign --input in.pdf --profile pades --timestamp https://tsa.example/rfc3161 --output b-t.pdf ``` **Flags:** | Flag | Type | Default | Description | |------|------|---------|-------------| -| `--input` | string | — (**required**) | Input PDF path | +| `--input` | string | stdin | Input PDF path | | `--output` | string | stdout | Signed PDF path | | `--key` | string | `PDFNATIVE_SIGN_KEY` env | Path to PEM private key file | | `--cert` | string | `PDFNATIVE_SIGN_CERT` env | Path to PEM certificate file | +| `--cert-chain` | string (repeatable) | `PDFNATIVE_SIGN_CHAIN` env | PEM intermediate CA file(s) | +| `--algorithm` | `rsa-sha256`\|`ecdsa-sha256` | `rsa-sha256` | Signature algorithm (ECDSA = P-256) | +| `--digest` | `sha256`\|`sha384`\|`sha512` | `sha256` | (v1.4.0) CMS digest — RSA only; ecdsa is sha256-only | +| `--profile` | `pkcs7`\|`pades` | `pkcs7` | (v1.4.0) `pades` = ETSI.CAdES.detached, PAdES B-B (ESS signing-certificate-v2, omits signing-time) | +| `--pure-crypto` | boolean | false | Force pdfnative's pure-JS signer (default: native constant-time `node:crypto`) | +| `--reason` / `--name` / `--location` / `--contact` / `--signing-time` | string | — | Signature metadata | +| `--timestamp` | TSA URL | — | (v1.4.0) **Opt-in network.** Embed a verified RFC 3161 timestamp token in the CMS unsigned attributes (PAdES B-T with `--profile pades`). Formerly a reserved flag | +| `--timestamp-digest` | `sha256`\|`sha384`\|`sha512` | `sha256` | (v1.4.0) TSA message-imprint digest | +| `--timestamp-nonce` | hex | random 8 bytes | (v1.4.0) TSA request nonce | +| `--allow-multiple` | boolean | false | (v1.4.0) Allow signing an already-signed PDF (appends a signature field; default is idempotent single-signature) | +| `--field-name` | string | auto | (v1.4.0) Signature form-field name | +| `--signature-rect` | `"x1,y1,x2,y2"` | invisible | (v1.4.0) Visible signature widget rectangle (PDF points) | +| `--signature-page` | integer | 1 | (v1.4.0) 1-based page for the signature widget | +| `--placeholder-bytes` | integer | auto estimate | (v1.4.0) Explicit `/Contents` placeholder size | + +Without `--timestamp` the command performs **no network I/O**; with it, the TSA request goes through the SSRF-guarded client (see §6). **Secret loading priority:** 1. `PDFNATIVE_SIGN_KEY` env var (PEM string of private key) @@ -397,6 +436,21 @@ const signerCert = parseCertificate(certDer); ``` > **Note:** `signPdfBytes` is synchronous — it returns `Uint8Array` directly (not a Promise). +> With `--timestamp`, the CLI instead calls the **async** `signPdfBytesWithTimestamp(bytes, options)` after injecting a `TimestampProvider` (built in [`src/utils/tsa.ts`](../src/utils/tsa.ts) over the SSRF-guarded client) via `setTimestampProvider` — the engine itself never opens a socket. + +--- + +### `verify` + +**Purpose:** Verify embedded CMS/PKCS#7 signatures — integrity, signature value, certificate chain, trust, RFC 3161 timestamps, and OCSP/CRL revocation. + +```bash +pdfnative verify [--input ] [--trust ]... [--strict] [--revocation offline|online|disabled] [--revocation-policy soft-fail|strict] [--format json|text] +``` + +Per signature the JSON report covers: byte-range integrity (CMS `messageDigest`), signer subject/issuer, chain validity, trust evaluation (against `--trust` PEM roots; self-signed accepted when omitted), cryptographic signature-value verification (RSA / ECDSA-P-256 — **SHA-256/384/512 CMS digests since v1.4.0**), RFC 3161 timestamp-token validation (PAdES-T), and revocation status (`--revocation offline` reads the PDF `/DSS`; `online` additionally fetches via AIA/CDP URLs through the SSRF guard; `disabled` skips). + +**v1.4.0:** each signature also reports its **`fieldName`**, and `/DocTimeStamp` revisions (PAdES B-LTA) are validated as RFC 3161 tokens and flagged **`isDocTimestamp: true`**. `--strict` exits 1 on any failing signature. Token-economy flags: `--summary` (`{ valid, signatures, invalid }`), `--fields`, `--pretty`. --- @@ -415,10 +469,16 @@ pdfnative inspect [--input ] [--format json|text] | `--input` | string | stdin | Input PDF path | | `--format` | `json`\|`text` | `json` | Output format | | `--verbose` | boolean | false | Add trailer keys, catalog keys, object count, XMP | -| `--pages` | boolean | false | Add per-page metadata array | +| `--pages` | boolean | false | Add per-page metadata array — width/height/rotation, annotation/formField/signature counts, and (v1.4.0) `cropBox` / `bleedBox` / `trimBox` / `artBox` / `userUnit` when present | | `--annotations` | boolean | false | List markup + link annotations per page (page labels reported automatically) | +| `--form-fields` | boolean | false | List AcroForm fields (name, type, value, required/read-only) | +| `--encryption` | boolean | false | Report the encryption scheme (algorithm, revision, opened-as) | +| `--password` | string | — | Password for an encrypted PDF (env `PDFNATIVE_PASSWORD`) | | `--pdfua` | boolean | false | Add a PDF/UA (ISO 14289-1) structural validation report | -| `--check` | `pdfa`\|`signed`\|`encrypted`\|`pdfua` (repeatable) | — | CI assertion; sets exit code (0 = pass, 1 = fail) | +| `--signatures` | boolean | false | (v1.4.0) List signature fields via `listSignatures` — `fieldName`, `subFilter`, `byteRange`, `isDocTimestamp`, `isPlaceholder`, `sigObjNum`, `contentsLength` (never the signature bytes) | +| `--check` | `pdfa`\|`signed`\|`encrypted`\|`pdfua`\|`"signatures>=N"` (repeatable, AND) | — | CI assertion; sets exit code (0 = pass, 1 = fail). `"signatures>=N"` is new in v1.4.0 | + +**v1.4.0:** `metadata.trapped` (`/Trapped`) is reported, and a bug where the per-page `signatures` / `formFields` counters always reported `0` is fixed. **JSON output shape:** ```json @@ -431,7 +491,7 @@ pdfnative inspect [--input ] [--format json|text] "metadata": { "title": "Monthly Report", "author": "Nizoka", - "creationDate": "2026-04-27T12:00:00+00:00" + "creationDate": "D:20260427120000+00'00'" } } ``` @@ -612,11 +672,135 @@ pdfnative annotate --input --output --annotations | `--input` | string | stdin | Source PDF | | `--output` | string | stdout | Annotated PDF | | `--annotations` | string | — **(required)** | JSON array (or `{ annotations: [...] }`); each entry a markup annotation + 1-based `page` | +| `--password` | string | — | (v1.4.0) Password for an encrypted PDF (env `PDFNATIVE_PASSWORD`); appended objects are encrypted under the existing scheme | Types: `text`, `highlight`, `underline`, `strikeout`, `squiggly`, `square`, `circle`, `line`, `freetext`. Each needs `page` + `rect` `[x1,y1,x2,y2]`; `line` also needs `start`/`end`. Only known fields are forwarded (no dictionary injection). **pdfnative API:** `createModifier(reader): PdfModifier`, `buildAnnotationBody(annotation: MarkupAnnotation)`, `modifier.addAnnotation(pageIndex, body)`, `modifier.save(): Uint8Array` (incremental). +### `metadata` (v1.4.0) + +**Purpose:** Update PDF `/Info` + XMP metadata with an **incremental save** — the original bytes are preserved as a prefix, so existing digital signatures remain valid for their revision. The XMP packet is kept in sync (`xmp:ModifyDate`, `pdf:Keywords`, …). Reading metadata stays in `inspect`. + +```bash +pdfnative metadata --input in.pdf --title "New title" [--output out.pdf] +pdfnative metadata --input in.pdf --from-json meta.json --output out.pdf +``` + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--input` / `--output` | string | stdin / stdout | I/O | +| `--title` / `--author` / `--subject` / `--keywords` | string | — | Metadata fields (keywords = single string) | +| `--mod-date` | ISO 8601 | now | Pass a fixed value for reproducible output | +| `--from-json` | string | — | JSON file `{ title?, author?, subject?, keywords?, modDate? }` — mutually exclusive with the per-field flags | +| `--password` | string | — | Password for an encrypted PDF (env `PDFNATIVE_PASSWORD`) | +| `--dry-run` | boolean | false | Validate inputs without writing output | + +At least one metadata field is required. + +**pdfnative API:** `createModifier(reader)`, `modifier.updateMetadata(update: PdfMetadataUpdate)`, `modifier.save()` (incremental). + +### `ltv` (v1.4.0) + +**Purpose:** PAdES B-LT — archive the certificates, OCSP responses and CRLs needed to validate the document's signatures long after certificates expire, into `/DSS` + `/VRI`. The two-step collect/embed flow supports **air-gapped pipelines**: collect on a connected machine, embed offline. + +```bash +pdfnative ltv collect --input signed.pdf --online [--output ltv.json] +pdfnative ltv embed --input signed.pdf --data ltv.json [--output out.pdf] +pdfnative ltv add --input signed.pdf --online [--output out.pdf] +``` + +| Subcommand | Network | Description | +|------------|---------|-------------| +| `collect` | **requires `--online`** | Fetch OCSP/CRL validation data and write a replayable JSON file (schema subject `ltv-data`) | +| `embed` | **never** | Embed a previously collected JSON into `/DSS` + `/VRI` | +| `add` | **requires `--online`** | collect + embed in one pass | + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--input` / `--output` | string | stdin / stdout | I/O | +| `--online` | boolean | false | **Explicit opt-in** for network fetches (SSRF-guarded, no redirects). Without it, `collect`/`add` refuse to run | +| `--prefer` | `ocsp`\|`crl` | `ocsp` | Preferred revocation source | +| `--extra-cert` | PEM file (repeatable) | — | Extra chain certificates | +| `--data` | string | — | Collected JSON file (**required** for `embed`) | +| `--timeout` | ms | 10000 | Network timeout | +| `--dry-run` | boolean | false | Validate inputs; no output, no network | + +**pdfnative API:** `collectValidationInfo` (with a CLI-injected `RevocationProvider` from [`src/utils/ltv-provider.ts`](../src/utils/ltv-provider.ts)), `embedValidationInfo`, `addValidationInfo`. + +**The typical PAdES ladder:** +``` +sign --timestamp --profile pades → B-T +ltv add --online → B-LT +doc-timestamp --url → B-LTA +ltv add --online → LTV for the doc-timestamp itself +``` + +### `doc-timestamp` (v1.4.0) + +**Purpose:** PAdES B-LTA — append a `/DocTimeStamp` signature field (SubFilter `/ETSI.RFC3161`, ISO 32000-2 §12.8.5) covering every byte of the document as an incremental revision; earlier revisions stay byte-identical. Repeat periodically to renew LTA protection. + +```bash +pdfnative doc-timestamp --input signed.pdf --url [--output out.pdf] +``` + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--input` / `--output` | string | stdin / stdout | I/O | +| `--url` | TSA URL | — **(required)** | RFC 3161 TSA — the explicit network opt-in (SSRF-guarded, no redirects) | +| `--digest` | `sha256`\|`sha384`\|`sha512` | `sha256` | Message-imprint digest | +| `--field-name` | string | `DocTimeStamp1` | Timestamp field name (auto-suffixed on collision) | +| `--placeholder-bytes` | integer | 12288 | `/Contents` placeholder size | +| `--nonce` | hex | random | Request nonce | +| `--timeout` | ms | 10000 | Network timeout | +| `--dry-run` | boolean | false | Validate inputs; no output, no network | + +**pdfnative API:** `addDocumentTimestamp(bytes, options)` with the CLI's `TimestampProvider`. + +### `compare` (v1.4.0) + +**Purpose:** Diff two PDFs by extracted reading-order **text** and/or **structure** (page count, page/print boxes, metadata, form fields, annotations, encryption, signatures). Built for CI and agents: identical documents exit 0; any difference exits 1 with the stable code `E_CHECK_FAILED`. **Visual/rasterised diffing is out of scope** (pdfnative has no rasteriser). + +```bash +pdfnative compare a.pdf b.pdf [--mode both] [--format text|json] [options] +``` + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| _positionals_ | 2 paths | — | The two PDFs to compare | +| `--mode` | `text`\|`structure`\|`both` | `both` | What to diff | +| `--format` | `text`\|`json` | `text` | Report format (stdout; schema subject `compare`) | +| `--tolerance` | points | 0 | Geometric tolerance for page/box sizes | +| `--ignore-whitespace` | boolean | false | Collapse runs of whitespace before the text diff | +| `--pages` | selector | all | 1-based selector limiting the text diff (e.g. `"1,3-5"`) | +| `--password-a` / `--password-b` | string | — | Per-side passwords | +| `--pretty` | boolean | false | Force indented JSON even under `--json` | + +**pdfnative API:** `extractText` + `openPdf`/`listSignatures`-based structural comparison (read-only). + +### `batch` + +**Purpose:** Render a directory of JSON definitions in parallel, **or** (v1.4.0) run a declarative multi-command manifest pipeline. + +```bash +pdfnative batch --input-dir --output-dir [render options] +pdfnative batch --manifest tasks.json [--allow-network] [--continue-on-error] +``` + +**Directory mode:** `--input-dir` (required) + `--output-dir`, `--concurrency` (default 4), `--fail-fast`; all other render flags are forwarded to each file. Exit 1 if any file fails. + +**Manifest mode (v1.4.0, mutually exclusive with `--input-dir`):** + +| Flag | Type | Description | +|------|------|-------------| +| `--manifest` | string | Pipeline file (schema subject `batch-manifest`): `{ "version": 1, "tasks": [ { "id", "command", "flags" } ] }`. Flag values `"@"` reference the output of an **earlier** task; relative paths resolve against the manifest's directory. Tasks run sequentially, fail-fast. Allowed commands (14): `render`, `sign`, `verify`, `inspect`, `merge`, `split`, `extract`, `extract-text`, `fill`, `encrypt`, `decrypt`, `annotate`, `metadata`, `doc-timestamp` (`ltv` and `compare` need positional arguments and are not yet manifest-callable) | +| `--allow-network` | boolean | **Required** for any network flag inside the manifest (`--timestamp`, `--url`, `--online`, `--revocation online`). An untrusted manifest can never trigger network I/O on its own | +| `--continue-on-error` | boolean | Keep running after a failure; tasks depending (via `@`) on a failed task are skipped | + +Common: `--format text|json`, `--summary` (`{ total, succeeded, failed }`), `--fields`, `--pretty`, `--dry-run`. Implemented by [`src/utils/manifest.ts`](../src/utils/manifest.ts) (parse/validate + `@id` resolution + network gate). + +A manifest has the filesystem access of the user who invokes `batch` — the same trust level as command-line flags; only network access is additionally gated behind `--allow-network`. The manifest file is size-capped (50 MB) and bounded to 1 000 tasks, and path values undergo the same anti-traversal check as direct CLI flags. Structural violations (wrong shape/types/version) exit 2 / `E_USAGE`; value violations (invalid or duplicate id, non-whitelisted command, bad `@ref`) exit 1 / `E_INPUT`. + ### `govern` **Purpose:** Expose pdfnative's AI-governance / Human-in-the-Loop (HITL) contract. Agents are **draftsmen** — a human always reviews and submits. @@ -637,7 +821,7 @@ pdfnative govern verify-issue [--json] # gate a draft pdfnative doctor [--format json|text] ``` -Reports the CLI version, Node version (≥ 20), Web Crypto (CSPRNG) availability — required by `encrypt` — the resolved `pdfnative` version, and the registered command count. `--format json` (or global `--json`) emits `{ ok, checks: [{ name, status, value, detail }] }`. Exit code **0** when all checks pass, **1** otherwise — so an agent can gate `encrypt` on `doctor` first. +Reports the CLI version, Node version (≥ 22), Web Crypto (CSPRNG) availability — required by `encrypt` — the resolved `pdfnative` version, and the registered command count. `--format json` (or global `--json`) emits `{ ok, checks: [{ name, status, value, detail }] }`. Exit code **0** when all checks pass, **1** otherwise — so an agent can gate `encrypt` on `doctor` first. --- @@ -662,7 +846,9 @@ Global `--json` sets `PDFNATIVE_JSON=1` (in `index.ts`). In that mode: - On **failure**, a single object is written to stderr: `{ "ok": false, "command": , "error": { "code": "E_*", "message": "…" } }`. -- On **success**, `render` / `sign` / `batch` emit a status line: +- On **success**, all the write commands — `render` / `sign` / `merge` / `split` / + `extract` / `annotate` / `fill` / `encrypt` / `decrypt` / `batch` / `metadata` / + `ltv` / `doc-timestamp` — emit a status line: `{ "ok": true, "command": "render", "variant": "document", "dryRun": false, "output": "out.pdf", "bytes": 12345 }`. - `inspect` / `verify` / `batch` already put their result document on stdout as JSON; `--json` only adds the stderr failure envelope (and forces `batch`'s @@ -689,6 +875,7 @@ carried on every `CliError.code`: | `E_POLICY` | `govern verify-issue` found a governance violation | | `E_UNSUPPORTED` | Reserved / not-yet-available capability | | `E_PASSWORD` | Encrypted PDF: password missing or incorrect | +| `E_NETWORK` | (v1.4.0) Opt-in network operation failed — TSA / OCSP / CRL transport error or non-2xx response (deliberately generic; never includes response bodies) | | `E_RUNTIME` | Catch-all runtime error | When no code is passed, `CliError` derives one from the exit code @@ -698,10 +885,11 @@ code for free. ### `--dry-run` `render`, `sign`, `batch`, `merge`, `split`, `extract`, `annotate`, `fill`, -`encrypt`, and `decrypt` accept +`encrypt`, `decrypt`, `metadata`, `ltv`, and `doc-timestamp` accept `--dry-run` (sets `PDFNATIVE_DRY_RUN=1`). Inputs are fully validated — and for `sign`, credentials are parsed and the PDF is placeholder-prepared — but **no -output is produced or written**. Commands read +output is produced or written**, and `--dry-run` **never performs network I/O**, +even when a network flag is present. Commands read `hasFlag(args.flags, 'dry-run') || isDryRun()` so a direct command call and the global flag both work. @@ -743,12 +931,24 @@ Human invocations (no `--json`) are unchanged. ### `schema` command [`src/commands/schema.ts`](../src/commands/schema.ts) prints a hand-authored, -versioned JSON Schema (Draft 2020-12) for `render` / `annotate` input, `inspect` -/ `verify` / `batch` / `govern-verify` output, or the `inspect-summary` / -`verify-summary` / `batch-summary` compact shapes. The `$id` embeds the CLI version +versioned JSON Schema (Draft 2020-12) for one of **19 subjects** (v1.4.0): + +- **Inputs:** `render` (default), `annotate`, `fill`, `metadata` (v1.4.0 — input for `metadata --from-json`), `batch-manifest` (v1.4.0 — input for `batch --manifest`) +- **Outputs:** `inspect`, `verify`, `batch`, `extract-text`, `form-export`, `govern-verify`, `doctor`, `ltv-data` (v1.4.0 — output of `ltv collect` / input for `ltv embed`), `compare` (v1.4.0) +- **Compact shapes:** `inspect-summary`, `verify-summary`, `batch-summary` +- **Meta:** `status` (the agent success envelope), `manifest` (the machine-readable capability manifest: commands, flags, codes) + +The `$id` embeds the CLI version (`https://pdfnative.dev/schema/cli//.schema.json`) so callers can detect drift. `schema list` enumerates the subjects. +**`batch --manifest` for agents (v1.4.0):** the manifest is the recommended way for an +agent to run a multi-step pipeline (e.g. render → sign → encrypt) in **one process +invocation** with a single JSON summary — validate it first against +`schema batch-manifest`, and remember that any network flag inside a manifest is +refused unless `batch` itself is invoked with `--allow-network` (an untrusted +manifest can never trigger network I/O on its own). + See [AGENTS.md](../AGENTS.md) for the agent-facing summary. --- @@ -759,9 +959,39 @@ See [AGENTS.md](../AGENTS.md) for the agent-facing summary. |--------|-----------| | Path traversal via `--input`/`--output`/`--key`/`--cert` | `validatePath()` checks for `../` before any `fs.readFile` / `fs.writeFile` | | Memory exhaustion via large JSON | 50 MB size check before `JSON.parse` | +| Zip-bomb PDF streams (untrusted input) | (v1.4.0) Global `--max-inflate-size ` caps the decompressed size of any single PDF stream while parsing (default 100 MiB), via pdfnative's `setMaxInflateOutputSize` | | Key material leakage via logs | Keys never included in error messages; `sign` command silences all key-related debug output | +| SSRF via opt-in network operations | Every network path goes through [`src/utils/fetch-guard.ts`](../src/utils/fetch-guard.ts) — see below | +| Untrusted batch manifests triggering network I/O | Network flags inside a `batch --manifest` are refused unless `batch` itself is invoked with `--allow-network` | | Binary injection via inspect output | All metadata fields are string-coerced; no raw binary blobs emitted | | Supply-chain risk | Zero extra runtime dependencies; OIDC-signed npm provenance; CodeQL + Scorecard CI; CycloneDX SBOM attached to each release | +| False PDF/A conformance claims | Blocking veraPDF CI gate (`.github/workflows/verapdf.yml` + pre-publish in `publish.yml`): a CLI-generated corpus is validated against the veraPDF reference validator, with negative canaries an "accepts-everything" validator would expose. veraPDF is an **external CI tool**, never bundled — the zero-extra-runtime-dependency policy is unchanged — and its pinned 1.30.2 installer's SHA-256 is verified before `java -jar` executes it | + +### Network model (v1.4.0) + +The CLI is **offline by default**. Network I/O happens **only** on these explicit opt-ins, +and never anywhere else: + +| Opt-in | What it fetches | +|--------|-----------------| +| `sign --timestamp ` | RFC 3161 timestamp token (TSA) | +| `doc-timestamp --url ` | RFC 3161 document timestamp (TSA) | +| `ltv collect` / `ltv add` `--online` | OCSP responses (AIA) + CRLs (CDP) | +| `verify --revocation online` | OCSP responses (AIA) + CRLs (CDP) | +| `batch --allow-network` | Only unlocks the flags above *inside* a manifest | + +Every one of these goes through the same SSRF-guarded HTTP(S) client +([`src/utils/fetch-guard.ts`](../src/utils/fetch-guard.ts)): + +- scheme allow-list (`http`/`https` only); +- DNS resolution with private / loopback / link-local / reserved address blocking (including the cloud-metadata range `169.254.169.254`); +- the connection is **pinned** to the vetted resolved IP (defeats DNS rebinding); +- hard request timeout (default 10 s) and response-size cap (default 5 MiB); +- **zero redirect following** (a redirect to an internal host would bypass the checks). + +Transport failures surface as the stable `E_NETWORK` code with deliberately generic +messages (no response bodies). `--dry-run` never performs network I/O, even when a +network flag is present. See [SECURITY.md](../SECURITY.md) for the full policy. @@ -812,9 +1042,20 @@ With `--stream`, the entire PDF must be consumed before the process exits. Use ` |------------|--------------------|-------------|-------| | `render` (default) | `buildDocumentPDFBytes(params)` | `Uint8Array` | Synchronous | | `render --stream` | `buildDocumentPDFStream(params)` | `AsyncGenerator` | Single-pass streaming | -| `render --stream-page-by-page` | `buildDocumentPDFPageStream(params)` | `AsyncGenerator` | Object-boundary streaming | +| `render --stream-page-by-page` | `buildDocumentPDFStreamPageByPage(params)` | `AsyncGenerator` | Object-boundary streaming | | `render --stream-true` | `buildDocumentPDFStreamTrue(params)` | `AsyncGenerator` | True constant-memory streaming | | `sign` | `signPdfBytes(bytes, options)` | `Uint8Array` | Synchronous; PEM parsed via `parseRsaPrivateKey` + `parseCertificate` | +| `sign --timestamp` (v1.4.0) | `signPdfBytesWithTimestamp(bytes, options)` | `Promise` | Async; the CLI injects a `TimestampProvider` (`setTimestampProvider`, [`utils/tsa.ts`](../src/utils/tsa.ts)) — pdfnative never opens a socket itself | +| `ltv collect` (v1.4.0) | `collectValidationInfo(bytes, options)` | `Promise` | Needs a `RevocationProvider` (`setRevocationProvider`, [`utils/ltv-provider.ts`](../src/utils/ltv-provider.ts)) — network via fetch-guard | +| `ltv embed` (v1.4.0) | `embedValidationInfo(bytes, data)` | `Uint8Array` | Offline; writes `/DSS` + `/VRI` as an incremental revision | +| `ltv add` (v1.4.0) | `addValidationInfo(bytes, options)` | `Promise` | collect + embed in one pass | +| `doc-timestamp` (v1.4.0) | `addDocumentTimestamp(bytes, options)` | `Promise` | Appends a `/DocTimeStamp` field (SubFilter `/ETSI.RFC3161`) incrementally | +| `inspect --signatures` / `verify` / `compare` (v1.4.0) | `listSignatures(bytes)` | `readonly PdfSignatureInfo[]` | fieldName, subFilter, byteRange, `isDocTimestamp`, `isPlaceholder` | +| `metadata` (v1.4.0) | `createModifier(reader)` → `modifier.updateMetadata(update)` → `modifier.save()` | `Uint8Array` | Incremental save; `update` is a `PdfMetadataUpdate`; XMP kept in sync | +| `compare` (v1.4.0) | `extractText(bytes, opts)` + `openPdf` | report | Read-only text + structural comparison | +| `render` print production (v1.4.0) | `layout.print` (`PrintOptions`), `layout.outputIntent`, `layout.viewerPreferences` | — | Bleed/Trim/Art/Crop boxes, printer's marks, `userUnit`; ICC RGB output intent; duplex / copies / print-range / tray hints | +| `render` PDF/A diagnostics (v1.4.0) | `diagnostics` reported by the builders | `diagnostics[]` | stderr warnings, `diagnostics[]` in the `--json` envelope, or a pre-output `E_CHECK_FAILED` under `--strict` | +| Global `--max-inflate-size` (v1.4.0) | `setMaxInflateOutputSize(bytes)` | — | Anti-zip-bomb cap on any single decompressed PDF stream (default 100 MiB, `DEFAULT_MAX_INFLATE_OUTPUT`) | | `inspect` (open) | `openPdf(bytes)` | `PdfReader` | Returns reader with `.getCatalog()`, `.getInfo()`, `.pageCount` etc. | **PdfDict helpers (from pdfnative):** @@ -842,6 +1083,10 @@ npm run build npm test npm run test:coverage +# PDF/A validation (veraPDF — external tool; without it the run SKIPs with exit 0) +npm run corpus:pdfa # generate the 12-file PDF/A corpus (needs a prior npm run build) +npm run validate:pdfa # build + corpus + veraPDF validation (see CONTRIBUTING.md) + # Typecheck npm run typecheck @@ -863,7 +1108,7 @@ Complete, runnable examples live in [`samples/`](../samples/), organized by feat | Category | Files | Description | |----------|-------|-------------| -| [`render/document/`](../samples/render/document/) | 5 | Minimal, report, all-blocks reference, invoice, technical spec | +| [`render/document/`](../samples/render/document/) | 6 | Minimal, report, all-blocks reference, invoice, technical spec, `--max-blocks` guard | | [`render/table/`](../samples/render/table/) | 2 | Project status, financial summary | | [`render/barcode/`](../samples/render/barcode/) | 3 | QR code, Code 128 shipping label, EAN-13 | | [`render/form/`](../samples/render/form/) | 2 | Contact form, survey | @@ -871,10 +1116,15 @@ Complete, runnable examples live in [`samples/`](../samples/), organized by feat | [`render/link/`](../samples/render/link/) | 1 | Resource directory with hyperlinks | | [`render/watermark/`](../samples/render/watermark/) | 2 | Draft and confidential watermarks | | [`render/layout/`](../samples/render/layout/) | 3 | US Letter, A5 portrait, A4 landscape | -| [`render/pdfa/`](../samples/render/pdfa/) | 3 | PDF/A-1b, PDF/A-2b, PDF/A-3b archival conformance | -| [`sign/`](../samples/sign/) | 2 scripts | Digital signature (Bash + PowerShell) | -| [`inspect/`](../samples/inspect/) | 4 scripts | JSON and text inspection (Bash + PowerShell) | -| [`streaming/`](../samples/streaming/) | 1 script | 200-section document via streaming render | +| [`render/pdfa/`](../samples/render/pdfa/) | 4 | PDF/A-1b, PDF/A-2b, PDF/A-2u, PDF/A-3b archival conformance (rendered with `--font latin --lang latin`; veraPDF-validated in CI) | +| [`render/chart/`](../samples/render/chart/) | 5 | Native vector charts — incl. (v1.4.0) stacked bars, area + dual axes, log-scale scatter, time x-axis | +| [`render/print/`](../samples/render/print/) | 2 | (v1.4.0) Print production (`layout.print` bleed/marks) + viewer preferences (duplex/copies/range/tray) | +| [`sign/`](../samples/sign/) | 9 demos | Digital signature (Bash + PowerShell) — incl. (v1.4.0) `06-timestamp.*` PAdES B-T, `08-ltv.*` full PAdES ladder, and `09-multiple-signatures.*` | +| [`inspect/`](../samples/inspect/) | 8 script pairs | JSON/text inspection, CI `--check` gates, PDF/UA, annotations, `--signatures` inventory (Bash + PowerShell) | +| [`metadata/`](../samples/metadata/) | 1 pair + JSON | (v1.4.0) Incremental `/Info` + XMP update that keeps signatures valid | +| [`compare/`](../samples/compare/) | 1 pair + 2 JSON | (v1.4.0) Text/structure diff of two rendered contracts (CI exit codes) | +| [`batch/`](../samples/batch/) | 3 pairs + manifest | Parallel directory render + (v1.4.0) `--manifest` render → encrypt → inspect pipeline | +| [`streaming/`](../samples/streaming/) | 3 demos | Streaming render: single-pass, page-by-page, `--stream-true` | Run all render samples at once: @@ -882,6 +1132,12 @@ Run all render samples at once: node samples/run-all.js ``` +`run-all.js` renders the `render/pdfa/` and `render/attachments/` samples with +`--font latin --lang latin`, so their outputs actually conform to their claimed +PDF/A level (ISO 19005 requires embedded fonts); those same renders are validated +against the veraPDF reference validator in the blocking CI gate (see +[CONTRIBUTING.md](../CONTRIBUTING.md#pdfa-validation-verapdf)). + See [`samples/README.md`](../samples/README.md) for the full block type reference and integration patterns. --- @@ -1044,18 +1300,19 @@ node samples/run-all.js ### Which block types are supported through the CLI? **Supported via JSON (CLI):** -heading, paragraph, list, table, spacer, pageBreak, barcode, link, toc, formField +heading, paragraph, list, table, spacer, pageBreak, barcode, link, toc, formField, chart, svg, image + +- `svg` — fully JSON-usable since pdfnative 1.5.0: `SvgBlock.data` is a **string** (SVG path `d` attribute or SVG markup). +- `image` — JSON-usable since v1.4.0 via `src` (JPEG/PNG path, resolved relative to the `--input` JSON's directory) or `dataBase64` (inline base64); the CLI converts either to the `Uint8Array` payload pdfnative expects. **Supported via layout options:** - ✅ `watermark` (text or image overlay with opacity, angle, position) - ✅ `headerTemplate`/`footerTemplate` (customizable header/footer across all pages) - ✅ `encryption` (AES-128/256 password protection) -- ✅ `tagged` (PDF/A-1b/2b/3b compliance + accessibility) +- ✅ `tagged` (PDF/A-1b/2b/2u/3b compliance + accessibility) - ✅ `compress` (FlateDecode stream compression) - ✅ Custom `pageWidth`, `pageHeight`, `margins`, `colors`, `fontSizes` - -**Not exposed via JSON (CLI):** -- `image`, `svg` (require binary `Uint8Array` — use Node.js API directly) +- ✅ (v1.4.0) `print` (bleed/trim/art/crop boxes, printer's marks, `userUnit`), `outputIntent` (ICC RGB), `viewerPreferences` (duplex, copies, print range, tray) **Full example with layout options:** ```json @@ -1109,26 +1366,20 @@ pdfnative render --input document.json --output report.pdf ### How do I generate PDFs with custom page sizes or layouts? -**Answer:** The CLI currently uses standard A4 page sizing. For custom layouts: +**Answer:** Directly from the CLI (since v0.2.0): -**Option 1 (No custom sizing):** Accept default A4 formatting -- Content automatically reflowed to fit -- Margins are optimized for readability +```bash +# Named sizes: a4 (default) | letter | legal | a3 | tabloid | a5 +pdfnative render --input doc.json --page-size letter --output out.pdf -**Option 2 (Full control):** Use `pdfnative` Node.js API -```typescript -import { buildDocumentPDFBytes } from 'pdfnative'; - -const pdf = buildDocumentPDFBytes( - { /* params */ }, - { - pageWidth: 500, - pageHeight: 700, - margins: { t: 40, r: 40, b: 40, l: 40 } - } -); +# Arbitrary WxH in points, plus margins ("top,right,bottom,left" or uniform N) +pdfnative render --input doc.json --page-size 500x700 --margin 40 --output out.pdf ``` +Every other `PdfLayoutOptions` field (columns, colors, fontSizes, headers/footers, +watermark, print production, viewer preferences, …) is reachable via `--layout +layout.json` — discover the shape with `pdfnative schema render`. + ### What's the difference between `render` and `inspect`? | Option | Purpose | Input | Output | @@ -1182,4 +1433,4 @@ See [SECURITY.md](../SECURITY.md) for the full policy. --- -*Last updated: 2026-04-27 | pdfnative-cli v0.1.0* +*Last updated: 2026-08-26 | pdfnative-cli v1.4.0* diff --git a/llms.txt b/llms.txt index 905bf2f..b34a1b6 100644 --- a/llms.txt +++ b/llms.txt @@ -20,17 +20,26 @@ machine-readable JSON manifest run `pdfnative schema manifest`. Place `--json` AFTER the sub-command name. - **Stable error codes** (branch on these, not on the message): `E_USAGE, E_INPUT, E_PARSE, E_IO, E_SIGN, E_VERIFY_FAILED, E_CHECK_FAILED,` - `E_POLICY, E_UNSUPPORTED, E_PASSWORD, E_RUNTIME`. + `E_POLICY, E_UNSUPPORTED, E_PASSWORD, E_NETWORK, E_RUNTIME`. +- **Offline by default**: the ONLY network opt-ins are `verify --revocation online`, + `sign --timestamp `, `ltv --online`, `doc-timestamp --url`, and + `batch --allow-network`; all SSRF-guarded, no redirects. Failures → `E_NETWORK`. - **Token economy**: `--summary` (minimal verdict), `--fields a,b.c` (dot-path projection), compact JSON by default under `--json` (`--pretty` opts out). - **`--dry-run`** validates inputs without writing output. -- **Self-description**: `pdfnative schema ` (Draft 2020-12) and +- **Self-description**: `pdfnative schema ` (Draft 2020-12, 19 subjects + incl. `metadata`, `ltv-data`, `compare`, `batch-manifest`) and `pdfnative schema manifest` (capability list) — validate before you invoke. +- **PAdES ladder**: `sign --timestamp --profile pades` (B-T) → + `ltv add --online` (B-LT) → `doc-timestamp --url ` (B-LTA) → + `ltv add --online`. ## Commands - `render` — JSON document/table → PDF (22 Unicode scripts, math, colour emoji, - bookmarks, native charts, watermarks, PDF/A, encryption, streaming). + bookmarks, native charts, watermarks, PDF/A, encryption, streaming). PDF/A + outputs are validated against the veraPDF reference validator in CI + (blocking); conformance recipe: `--tagged pdfa --font latin --lang latin`. - `extract-text` — reading-order Unicode text as `text | json | ndjson` (`--runs` for positioned runs, `--password` for encrypted PDFs). No OCR. - `fill` — fill, flatten, and/or **export** an AcroForm (`--data values.json`, @@ -42,12 +51,30 @@ machine-readable JSON manifest run `pdfnative schema manifest`. - `merge` / `split` / `extract` — page-tree ops; support encrypted sources (`--password`), output re-encryption (`--encrypt`), and constant-memory streaming (`--stream`). -- `annotate` — attach markup annotations via incremental save. +- `annotate` — attach markup annotations via incremental save (`--password` for + encrypted PDFs). +- `metadata` — update /Info + XMP metadata via incremental save (existing + signatures stay valid); reading metadata stays in `inspect`. - `sign` / `verify` — CMS/PKCS#7 RSA & ECDSA signing (native constant-time - crypto) and verification with LTV (RFC 3161 timestamps, OCSP, CRL). -- `inspect` — metadata, conformance, signatures, annotations, form fields - (`--form-fields`), encryption scheme (`--encryption`), PDF/UA validation. -- `batch` — render a directory of JSON inputs in parallel. + crypto; `--timestamp ` for RFC 3161 / PAdES B-T, `--profile pades`, + `--digest sha256|sha384|sha512`, `--allow-multiple` for multi-signatures, + visible `--signature-rect`) and verification with LTV (RFC 3161 timestamps, + /DocTimeStamp revisions, OCSP, CRL). +- `ltv` — PAdES B-LT: `collect` (needs `--online`) / `embed` (100% offline) / + `add` — archive OCSP+CRL validation data into /DSS + /VRI. +- `doc-timestamp` — PAdES B-LTA: append an RFC 3161 /DocTimeStamp revision + (`--url ` required; repeat to renew). +- `compare` — diff two PDFs by text + structure; identical → exit 0, + different → exit 1 / `E_CHECK_FAILED`. No visual/raster diff. +- `inspect` — metadata, conformance, signatures (`--signatures` inventory), + annotations, form fields (`--form-fields`), encryption scheme + (`--encryption`), PDF/UA validation, `--check "signatures>=N"`. +- `batch` — render a directory of JSON inputs in parallel, or run a + declarative multi-command pipeline (`--manifest tasks.json`, `@` output + references, `--allow-network` gate). 14 whitelisted manifest commands: + render, sign, verify, inspect, merge, split, extract, extract-text, fill, + encrypt, decrypt, annotate, metadata, doc-timestamp (ltv and compare need + positional arguments and are not yet manifest-callable). - `govern` — AI-governance / Human-in-the-Loop contract (`rules`, `policy`, `verify-issue`). - `schema` / `completion` — self-description and shell completions @@ -68,4 +95,4 @@ gates drafts (exit `1` / `E_POLICY` on violation). See AGENTS.md. - README.md — features, examples, command reference. - AGENTS.md — the full agent-automation contract. - docs/KNOWLEDGE_BASE.md — deep reference. -- Requires Node ≥ 20 (also runs on Bun and Deno). MIT licensed. +- Requires Node ≥ 22 (also runs on Bun and Deno). MIT licensed. diff --git a/package-lock.json b/package-lock.json index e0639ce..d949878 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "pdfnative-cli", - "version": "1.3.0", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pdfnative-cli", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "dependencies": { - "pdfnative": "^1.6.0" + "pdfnative": "^1.7.0" }, "bin": { "pdfnative": "dist/cli.cjs" @@ -24,7 +24,7 @@ "vitest": "^4.1.7" }, "engines": { - "node": ">=20" + "node": ">=22" }, "funding": { "type": "individual", @@ -1718,9 +1718,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { @@ -2045,9 +2045,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -2765,9 +2765,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -3230,9 +3230,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -3367,9 +3367,9 @@ "license": "MIT" }, "node_modules/pdfnative": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/pdfnative/-/pdfnative-1.6.0.tgz", - "integrity": "sha512-gzwDxXD8iMLM5tSd86RQwIiX0gh9Oe2IzpYCnOgVzsHqOIPnbOZdiHWRkxZphAXIofEdrqBb5Zr/uhSBiVyD7w==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/pdfnative/-/pdfnative-1.7.0.tgz", + "integrity": "sha512-GTU2XFmhhbJrbX67ctYU5ctleBdUx9ClbEMKnB4QJSgB2VN5Xylylxm+Tj39BfFnNt/yGldzJLhSwhC1WNnJgg==", "license": "MIT", "bin": { "pdfnative-build-emoji-font": "dist/tools/build-emoji-font.js", diff --git a/package.json b/package.json index 668897f..807e350 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "pdfnative-cli", - "version": "1.3.0", - "description": "Official CLI for pdfnative — render JSON to PDF (22 Unicode scripts, math, COLRv1 colour emoji, native vector charts, bookmarks, streaming), extract text (RAG/agents), fill & flatten AcroForms, encrypt/decrypt (AES-128/256), merge/split/extract pages, annotate, sign (RSA + ECDSA, native constant-time crypto), inspect, validate PDF/UA, verify CMS signatures with LTV (RFC 3161, OCSP, CRL), and drive it all from AI agents under a human-in-the-loop governance contract. Zero extra runtime dependencies.", + "version": "1.4.0", + "description": "Official CLI for pdfnative — render JSON to PDF (22 Unicode scripts, math, COLRv1 colour emoji, 9 native vector chart types, print production with bleed/printer marks, bookmarks, streaming), extract text (RAG/agents), fill & flatten AcroForms, encrypt/decrypt (AES-128/256), merge/split/extract pages, annotate, edit metadata, compare PDFs, sign (RSA + ECDSA, native constant-time crypto, multiple signatures), timestamp (RFC 3161, PAdES B-T), embed LTV validation info (/DSS, PAdES B-LT), add document timestamps (PAdES B-LTA), inspect, validate PDF/UA, verify CMS signatures with LTV (RFC 3161, OCSP, CRL), orchestrate multi-step pipelines, and drive it all from AI agents under a human-in-the-loop governance contract. Zero extra runtime dependencies.", "type": "module", "bin": { "pdfnative": "./dist/cli.cjs" @@ -26,6 +26,8 @@ "typecheck": "tsc --noEmit", "typecheck:tests": "tsc --project tsconfig.test.json --noEmit", "typecheck:all": "npm run typecheck && npm run typecheck:tests", + "corpus:pdfa": "node scripts/generate-pdfa-corpus.mjs", + "validate:pdfa": "npm run build && npm run corpus:pdfa && node scripts/validate-pdfa.mjs", "prepublishOnly": "npm run build" }, "keywords": [ @@ -114,7 +116,23 @@ "llm", "llms-txt", "mcp", - "powershell-completion" + "powershell-completion", + "pdf-compare", + "pdf-diff", + "document-timestamp", + "timestamping", + "tsa", + "dss", + "pades-lt", + "pades-lta", + "pdf-metadata", + "xmp", + "print-production", + "bleed", + "printer-marks", + "output-intent", + "pdf-manifest", + "pipeline" ], "author": "Nizoka (https://pdfnative.dev)", "license": "MIT", @@ -131,14 +149,14 @@ "url": "https://plika.app" }, "engines": { - "node": ">=20" + "node": ">=22" }, "publishConfig": { "access": "public", "provenance": true }, "dependencies": { - "pdfnative": "^1.6.0" + "pdfnative": "^1.7.0" }, "devDependencies": { "@types/node": "^22.0.0", @@ -151,7 +169,8 @@ }, "overrides": { "esbuild": "^0.28.1", - "js-yaml": "^4.3.0", + "js-yaml": "^4.3.1", + "nanoid": "^3.3.18", "vite": "^8.0.16" } } diff --git a/release-notes/draft/PR-v1.4.0.md b/release-notes/draft/PR-v1.4.0.md new file mode 100644 index 0000000..d10776e --- /dev/null +++ b/release-notes/draft/PR-v1.4.0.md @@ -0,0 +1,212 @@ +# v1.4.0 — PAdES B-T/B-LT/B-LTA, compare, metadata & manifest pipelines + +> **Branch:** release/v1.4.0 → main +> **Type:** Minor release (additive, 100% backward-compatible command surface with v1.3.0) +> **pdfnative bump:** ^1.6.0 → ^1.7.0 +> **Support policy:** Node.js ≥ 22 (was ≥ 20 — Node 20 EOL 2026-04-30); CI matrix 22 + 24 + +## Summary + +1. Completes the ROADMAP "Next" section: sign-side LTV. `sign --timestamp ` + (RFC 3161, PAdES B-T) is now functional; new `ltv collect|embed|add` command writes + `/DSS` + `/VRI` (B-LT, with an air-gapped collect→embed flow); new `doc-timestamp` + command appends `/DocTimeStamp` revisions (B-LTA). +2. Two more new commands: `metadata` (incremental `/Info` + XMP updates that keep + signatures valid) and `compare` (text/structure diff with CI exit codes). +3. Multi-signature support end-to-end: `sign --allow-multiple/--field-name/--profile/ + --digest/--signature-rect/--signature-page/--placeholder-bytes`, inventoried by + `inspect --signatures`, validated per field by `verify` (incl. SHA-384/512 and + `/DocTimeStamp` token validation). +4. `batch --manifest tasks.json` — declarative multi-command pipelines with `@id` output + references; network inside a manifest requires `--allow-network`. +5. `render`: `--strict` PDF/A diagnostics, JSON-usable image blocks (`src`/`dataBase64`), + print production (bleed/boxes/marks/userUnit/ICC output intent), viewer print + preferences, charts v2 (9 kinds, dual axes, log/time), `--chunk-size`, + `params.metadata` (+`trapped`). +6. Agent surface: new stable code `E_NETWORK`, `schema` subjects 15 → 19 (`ltv-data`, + `compare`, `batch-manifest`, `metadata`), global `--max-inflate-size`, completions + + manifest + `llms.txt` for all 21 commands. +7. Fixes a long-standing `inspect` bug (signature/form-field counters always 0). +8. veraPDF integration: the CLI's PDF/A claims are now validated against the veraPDF + reference validator — a 12-file CLI-generated corpus (10 positive + 2 negative + canaries) checked in a **blocking** CI workflow and again pre-publish; local gate + via `npm run validate:pdfa` (exit 0 without veraPDF = skip, not a pass). The PDF/A + samples themselves are now rendered actually-conformant (`--font latin --lang latin` + via `run-all.js`). + +## Changes + +### package.json +- `version` 1.3.0 → 1.4.0; `pdfnative` `^1.6.0` → `^1.7.0`; `engines.node` `>=20` → `>=22`. +- Description + keywords extended (pdf-compare, document-timestamp, dss, pades-lt/lta, + print-production, …). Security overrides refreshed (`js-yaml ^4.3.1`, `nanoid ^3.3.18`). + +### src/core-bridge/index.ts +- Selective re-exports for the 1.7.0 surface: `signPdfBytesWithTimestamp`, + `estimateContentsSize`, `collectValidationInfo` / `embedValidationInfo` / + `addValidationInfo` / `vriKeyForContents`, `addDocumentTimestamp`, `listSignatures`, + timestamp/revocation provider get/set, RFC 3161 token parsers, + `setMaxInflateOutputSize`, DER/hash/RSA primitives for the offline mock PKI, and all + associated types (print production, diagnostics, viewer preferences, metadata update, + image blocks). + +### New commands +- `src/commands/ltv.ts` — collect (network, `--online` mandatory) / embed (offline) / add; + serialises `LtvData` as versioned base64 JSON (schema subject `ltv-data`). +- `src/commands/docTimestamp.ts` — `doc-timestamp` (B-LTA), `--url` mandatory opt-in. +- `src/commands/metadata.ts` — incremental `updateMetadata` (signatures preserved). +- `src/commands/compare.ts` — text/structure diff, `E_CHECK_FAILED` on differences. + +### Enhanced commands +- `sign` — functional `--timestamp` (+`--timestamp-digest`, `--timestamp-nonce`), + multi-signatures, `--profile`, `--digest`, visible-signature placement, placeholder + sizing via `estimateContentsSize(..., { timestamp: true })`. No flags → byte-identical + 1.3.x path. +- `verify` — RSA SHA-384/512 OIDs, per-signer digest for the byte-range hash, + `/DocTimeStamp` validation (imprint + token signature), additive `fieldName` / + `isDocTimestamp`. +- `inspect` — `--signatures`, page boxes + `/UserUnit`, `metadata.trapped`, + `--check "signatures>=N"`; fixed name-object comparisons in the legacy counters. +- `annotate` — `--password` (encrypted incremental updates). +- `render` — `--strict` + diagnostics routing, image-block resolution, `--chunk-size`, + ICC `outputIntent` revival from JSON (`number[]` → bytes). +- `batch` — `--manifest` / `--allow-network` / `--continue-on-error` (validation before + execution, whitelist, `@id` graph, fail-fast). +- Global `--max-inflate-size` (src/index.ts, dynamic import keeps startup fast). + +### New / changed utilities +- `src/utils/tsa.ts` — `createTsaProvider(url)` : RFC 3161 POST over the existing SSRF + guard (`E_NETWORK` on failure, response bodies never echoed). +- `src/utils/ltv-provider.ts` — `createRevocationProvider()` : OCSP POST / CRL GET over + the same guard. +- `src/utils/manifest.ts` — pure manifest parsing/validation/@-resolution/network policy. +- `src/utils/keys.ts` — native crypto provider gains per-call digest selection + (sha256/384/512). +- `src/utils/layout.ts` — revives `outputIntent.iccProfile` from JSON (`number[]` → + bytes) so ICC output intents are reachable from `--layout` files. +- `src/utils/cms-verify.ts`, `src/utils/timestamp-verify.ts` — digest agility + + `verifyDocTimestamp()`. +- `src/utils/error.ts` / `agent.ts` — `E_NETWORK` (auto-published in `schema manifest`). + +### Wiring (single source of truth respected) +- `src/index.ts` — USAGE 17 → 21 (+ 4 `*_USAGE` blocks, help switch, `loadCommand`). +- `src/commands/completion.ts` — 4 new commands + all new flags (drives 4 shells + the + capability manifest). +- `src/commands/schema.ts` — subjects 15 → 19 + extended render/inspect/verify/batch/ + status schemas. + +### Samples +- New pairs (`.sh` + `.ps1`, offline by default; network steps gated on + `PDFNATIVE_TSA_URL`): `sign/06-timestamp` (replaces `06-timestamp-reserved`), + `sign/08-ltv` (full PAdES ladder), `sign/09-multiple-signatures`, + `inspect/08-list-signatures`, `metadata/01-update-metadata`, `compare/01-compare`, + `batch/03-manifest` (+ `manifest/tasks.json`). +- New render JSONs (auto-discovered by `run-all.js`): `render/print/01-bleed-marks`, + `render/print/02-viewer-prefs`, `render/chart/03-stacked-bars`, + `render/chart/04-area-scatter`, `render/chart/05-time-axis`. + +### scripts/ & workflows (veraPDF PDF/A gate) +- `scripts/generate-pdfa-corpus.mjs` — drives the **built** CLI to write a 12-file + PDF/A corpus to `test-output/pdfa/` + `manifest.json`: 10 positive entries + (`--strict --font latin --lang latin` across 1b/2b/2u/3b, attachments, + headers/footers, outline, opaque watermark, incremental PAdES sign, incremental + `metadata`) and 2 negative canaries veraPDF must reject (no-fonts render — + ISO 19005-2 §6.2.11.4.1; `--variant table` — ISO 19005-1 §6.3.4, the table path + cannot embed fonts from the CLI). +- `scripts/validate-pdfa.mjs` — validates each file against its claimed XMP profile + with veraPDF and compares with `expectCompliant`. Outcomes PASS/FAIL/XFAIL/XPASS/ + INFRA/SKIP; exit 0 ok/skip · 1 conformance (incl. fatal XPASS + coverage canary) · + 2 no corpus · 3 INFRA. `VERAPDF_REQUIRED=1` fail-closed; `VERAPDF_HOME`, + `VERAPDF_REPORT_DIR` supported; Windows `.bat` launcher handled. +- `package.json` — new scripts `corpus:pdfa` and `validate:pdfa`. +- `.github/workflows/verapdf.yml` — **blocking** (no `continue-on-error`), pinned + veraPDF 1.30.2 installer with SHA-256 verified before `java -jar`, report + raw + XML uploaded as artifact and rendered in the job summary. +- `.github/workflows/publish.yml` — the same veraPDF gate repeated pre-publish. +- Zero npm dependencies added — veraPDF is an external tool, never bundled. + +### Docs +- README (What's new, Highlights, Supported Features group, "PDF/A status" callout, + Quick Start, 21-command reference, Node ≥ 22), KNOWLEDGE_BASE (§2/4/5/6/8/9/10), + CONTRIBUTING.md (new "PDF/A validation (veraPDF)" section: scripts, exit codes, + skip semantics, install recipes, PR checklist), CLAUDE.md, AGENTS.md, llms.txt, ROADMAP + (v1.4.0 released; Next cleared; deferred items recorded), samples/README, + CHANGELOG, release-notes/v1.4.0.md, CITATION.cff re-synchronised (was 1.2.0 / "six + composable commands"), CI matrix 22/24. +- Factual corrections: `svg` blocks were wrongly documented as non-JSON-usable; + `math` was missing from the `render --font` list. + +### Tests +- `tests/helpers/der.ts` + `tests/helpers/mock-pki.ts` — offline mock PKI (root CA, + signer with AIA/CRL-DP URLs, TSA, OCSP responder) issuing genuine DER structures, + ported from pdfnative's own unpublished test helper; validated against the library's + parsers. +- New/extended suites: sign (timestamp + multisig), ltv (+doc-timestamp), verify + (digests + DocTimeStamp), inspect/annotate/pagetree (boxes preservation), render + (charts v2 / print / strict / images), metadata, compare, batch manifest, schema. +- **600 tests, 40 files, all green** (452 in v1.3.0). Coverage above the enforced + thresholds (statements 79 / branches 68 / functions 83 / lines 79 — unchanged). + +## Independent audit (this release) + +- **V1 — double-blind gap analysis (2 agents)** before implementation: full pdfnative + ≤ 1.7.0 public surface vs planned CLI surface. Consensus finding (both auditors): + image blocks were the only generation capability unreachable from the CLI → fixed. + Additional accepted findings: verify/digest coherence, `/DocTimeStamp` validation, + `annotate --password`, print-box inspection, visible-signature flags, + `--max-inflate-size`, `render --chunk-size`, two documentation corrections. Deferred + to ROADMAP: custom TTF loading, link annotations on existing PDFs, doctor language + enumeration. +- **V3 — post-implementation conformance review**: two independent reviewers (factual + accuracy of docs vs code; 2026 open-source standards) plus an arbiter judging finding + legitimacy; accepted findings applied. (Reports summarised in this PR's discussion.) + +## Validation + +- `npm run typecheck:all` — clean · `npm run lint` — 0 errors · + `npm run test:coverage` — 600/600, thresholds met · `npm run build` — ok · + `npm audit --audit-level=high` — 0 vulnerabilities. +- Built-binary smoke (`node dist/cli.cjs`): `--version` = 1.4.0, `--help`, + `schema manifest` (21 commands, `E_NETWORK`), completions include the new commands, + and an end-to-end render → metadata → compare → inspect --signatures round-trip. +- `node samples/run-all.js` green; new `.sh`/`.ps1` samples executed offline on + Git Bash + PowerShell. +- `npm run validate:pdfa` with veraPDF 1.30.2 installed locally: **10 PASS + + 2 XFAIL** (both negative canaries correctly rejected by the validator), exit 0. +- Zero-network guarantee in tests: mock providers injected via + `setTimestampProvider`/`setRevocationProvider`, RFC 2606 `.invalid` URLs. + +## Backward compatibility + +- No existing flag, default, exit code, error code or envelope changed. All new JSON + fields are optional/additive; `sign` without new flags follows the 1.3.x code path + byte-for-byte. +- `sign --timestamp` was a *reserved* flag whose error message announced future + availability — activating it is the documented contract, not a break. +- Schema `$id`s embed the CLI version and moved 1.3.0 → 1.4.0 (expected, pinned by + tests). +- Node ≥ 22 is a support-policy change (EOL alignment + upstream engines), not an API + change. +- Inherited pdfnative 1.7.0 byte-level changes (forms `/ToUnicode`, RTL fixes, box + preservation) are documented in the CHANGELOG. + +## Out of scope (recorded in ROADMAP) + +- `optimize` (linearisation/recompression) — still blocked upstream. +- Visual `compare` — no rasteriser upstream. +- Arbitrary object `modify` — `metadata` covers the metadata slice only. +- `render --font-file` (custom TTFs), link annotations on existing PDFs, doctor + language-pack enumeration, dedicated TSA timeout flags. + +## Self-review checklist + +- [x] `npm run typecheck:all` clean +- [x] `npm run lint` 0 errors +- [x] `npm run test:coverage` green, thresholds unchanged and met +- [x] `npm run build` + built-binary smoke test (`node dist/cli.cjs --help`, new + commands, `schema manifest`) +- [x] CHANGELOG.md updated (Keep a Changelog) +- [x] No breaking change to the machine contract (envelopes, exit codes, `E_*`) +- [x] No new runtime dependency (`pdfnative` remains the only one) +- [x] Docs + samples + completions + schemas cover the whole 21-command surface +- [x] No autonomous GitHub writes — this draft is committed for human review (HITL) diff --git a/release-notes/v1.4.0.md b/release-notes/v1.4.0.md new file mode 100644 index 0000000..bfe75df --- /dev/null +++ b/release-notes/v1.4.0.md @@ -0,0 +1,157 @@ +# pdfnative-cli v1.4.0 + + + +_Released 2026-08-26_ + +Built on **pdfnative 1.7.0**. This release completes the PAdES ladder promised on the +roadmap — trusted timestamps at signing time (**B-T**), long-term validation data +(**B-LT**) and document timestamps (**B-LTA**) — and grows the CLI from 17 to +**21 commands** with `ltv`, `doc-timestamp`, `metadata` and `compare`, plus +multi-signature support, declarative `batch --manifest` pipelines, print production, +PDF/A strict diagnostics, charts v2 and JSON-usable image blocks. The command surface +is 100% backward-compatible; network I/O remains strictly opt-in and SSRF-guarded. + +## Highlights + +- **The full PAdES ladder, composable:** + ```bash + pdfnative sign --timestamp https://tsa.example/tsr --profile pades ... # B-T + pdfnative ltv add --online -i signed.pdf -o lt.pdf # B-LT + pdfnative doc-timestamp --url https://tsa.example/tsr -i lt.pdf -o lta.pdf # B-LTA + pdfnative ltv add --online -i lta.pdf -o final.pdf # LTV for the doc-timestamp + ``` +- **Air-gapped LTV** — `ltv collect --online` emits a replayable JSON file + (schema subject `ltv-data`); `ltv embed` applies it fully offline. +- **`compare a.pdf b.pdf`** — text + structure diffing with CI-friendly exit codes + (identical → 0, different → 1 / `E_CHECK_FAILED`). +- **`metadata`** — incremental `/Info` + XMP updates that keep existing signatures valid. +- **`batch --manifest tasks.json`** — declarative render → sign → encrypt pipelines with + `@id` output references; an untrusted manifest can never trigger network I/O without + `--allow-network`. +- **Multiple signatures** — `sign --allow-multiple --field-name Approval2`, inventoried by + `inspect --signatures` and validated per field by `verify`. +- **Print production** — bleed/trim/art boxes, vector printer marks, `/UserUnit`, RGB ICC + output intents, duplex/copies viewer preferences; boxes now survive + merge/split/extract. +- **Charts v2** — 9 chart kinds, secondary axis, log/time axes, data labels. +- **`render --strict`** — PDF/A diagnostics escalate to a hard error before any output byte. +- **PDF/A claims are now proven, not asserted** — every PDF/A-claiming output is validated + against the **veraPDF** reference validator in CI (blocking), including negative canaries + the validator must reject. Locally: `npm run validate:pdfa` (skips with exit 0 when + veraPDF is absent — a skip, not a pass). Conformance recipe: + `--tagged pdfa --font latin --lang latin`. + +## What's new + +### New commands + +- **`ltv collect|embed|add`** — PAdES B-LT `/DSS` + `/VRI` via pdfnative 1.7.0 + `collectValidationInfo` / `embedValidationInfo` / `addValidationInfo`. `--online` is + mandatory for any fetching (OCSP preferred, CRL fallback — `--prefer`), `--extra-cert` + completes chains out-of-band, and `embed` never touches the network. +- **`doc-timestamp`** — appends a `/DocTimeStamp` revision (`/SubFilter /ETSI.RFC3161`, + ISO 32000-2 §12.8.5) covering every byte; earlier revisions stay byte-identical. Repeat + to renew LTA protection. +- **`metadata`** — `--title/--author/--subject/--keywords/--mod-date/--from-json`, + incremental save (signatures preserved), XMP kept in sync. +- **`compare`** — `--mode text|structure|both`, `--format json` (schema subject + `compare`), `--tolerance`, `--ignore-whitespace`, `--pages`, per-file passwords. Visual + diffing is explicitly out of scope (no rasteriser upstream). + +### sign + +- `--timestamp ` is now functional (previously a reserved flag that errored with + `E_UNSUPPORTED`, as its message announced) with `--timestamp-digest` and + `--timestamp-nonce`; failures are `E_NETWORK`/`E_PARSE` with **no silent fallback** to + an untimestamped signature. +- `--allow-multiple`, `--field-name`, `--profile pkcs7|pades`, + `--digest sha256|sha384|sha512`, `--signature-rect`, `--signature-page`, + `--placeholder-bytes`. + +### verify + +- Verifies RSA SHA-384/512 CMS signatures (`rsa-sha384` / `rsa-sha512`). +- Validates `/DocTimeStamp` revisions as RFC 3161 tokens; reports `fieldName` and + `isDocTimestamp` per signature (additive fields). + +### inspect / annotate + +- `inspect --signatures` (structural inventory, never the signature bytes), + print-production page boxes + `/UserUnit`, `metadata.trapped`, + `--check "signatures>=N"`. +- `annotate --password` for encrypted PDFs. + +### render + +- `--strict`, stderr `warning:` diagnostics + `diagnostics[]` in the `--json` envelope. +- Image blocks from JSON: `src` (path) or `dataBase64` (inline). +- `layout.print` (bleed/boxes/marks/userUnit), `layout.outputIntent` (RGB ICC), + `layout.viewerPreferences` (duplex, pickTrayByPDFSize, printPageRange, numCopies), + `params.metadata` (incl. `trapped`), charts v2, `--chunk-size`. + +### Agent surface + +- New stable error code **`E_NETWORK`**; `schema` subjects 15 → **19** (`ltv-data`, + `compare`, `batch-manifest`, `metadata`); extended `render`/`inspect`/`verify`/ + `batch`/`status` schemas; global `--max-inflate-size` (anti zip-bomb, default + 100 MiB); capability manifest, completions (bash/zsh/fish/powershell) and + `llms.txt` cover all 21 commands. + +## Compatibility + +- **100% backward-compatible command surface** — no flag, default, exit code, error code + or envelope changed; every addition is opt-in or additive. +- **Support policy: Node.js ≥ 22** (was ≥ 20; Node 20 reached EOL on 2026-04-30 and + pdfnative 1.7.0 declares `engines.node >= 22`). CI now tests Node 22 + 24. +- Inherited pdfnative 1.7.0 byte-level improvements: form-bearing documents gain fully + searchable text, RTL shaping fixes, and merge/split/extract preserve + Bleed/Trim/Art boxes + `/UserUnit`. Outputs remain spec-valid but are not byte-identical + to 1.3.0 for those documents. + +## Security + +- All new network paths (TSA, OCSP, CRL) are **opt-in only** (`--timestamp`, `--url`, + `--online`, `batch --allow-network`) and go through the existing SSRF guard: http/https + only, private/loopback/metadata addresses blocked, DNS pinning, 10 s timeout, 5 MiB + response cap, no redirects. Response bodies are never echoed into CLI output. +- Global `--max-inflate-size` caps decompression while parsing untrusted PDFs. +- Dev-dependency overrides refreshed (`js-yaml ^4.3.1`, `nanoid ^3.3.18`); `npm audit` + clean. + +## Fixed + +- **`inspect` signature/form-field counters were always 0** — parsed PDF name objects were + compared against raw strings and never matched; both counters now use the parser's + `nameValue`. + +## Notes + +- The whole PAdES ladder is tested offline against an in-process mock PKI issuing genuine + RFC 3161 tokens, OCSP responses and CRLs — zero network, zero binary fixtures. +- Samples for every new capability ship as `.sh` + `.ps1` pairs, offline by default; + timestamp/LTV samples run their network step only when `PDFNATIVE_TSA_URL` is set. +- Still tracked upstream (see ROADMAP): `optimize` (linearisation), visual `compare` + (rasteriser), arbitrary object `modify`. + +## Install + +```bash +npm install -g pdfnative-cli@1.4.0 +``` + +## Upgrade + +No action required from 1.x — all existing invocations behave identically on Node ≥ 22. + +## Verification + +- 600 vitest tests green on Node 22/24; coverage above the enforced thresholds. +- PDF/A validation corpus (12 files) validated with veraPDF 1.30.2 locally: 10 PASS + + 2 XFAIL (negative canaries correctly rejected), exit 0. The same gate runs blocking + in CI (`verapdf.yml`, SHA-256-verified pinned installer) and pre-publish + (`publish.yml`). +- `npm audit` clean; built binary smoke-tested (`node dist/cli.cjs …`) for every command. +- SBOM (CycloneDX) and npm provenance attached by the release workflow. Consumers + can verify the published package's provenance and registry signatures with + `npm audit signatures` after installing. diff --git a/samples/README.md b/samples/README.md index 92e8876..adfb9b3 100644 --- a/samples/README.md +++ b/samples/README.md @@ -13,7 +13,7 @@ A comprehensive collection of sample files covering every feature of pdfnative-c 2. ✅ View sample JSON: [render/document/01-minimal.json](render/document/01-minimal.json) 3. ✅ Try a different feature: `node samples/run-all.js --category barcode` 4. ✅ Read the docs: [../docs/KNOWLEDGE_BASE.md](../docs/KNOWLEDGE_BASE.md) -5. ✅ Check FAQ: [../docs/KNOWLEDGE_BASE.md#11-frequently-asked-questions](../docs/KNOWLEDGE_BASE.md#11-frequently-asked-questions) +5. ✅ Check FAQ: [../docs/KNOWLEDGE_BASE.md#12-frequently-asked-questions](../docs/KNOWLEDGE_BASE.md#12-frequently-asked-questions) --- @@ -51,7 +51,7 @@ pdfnative render ` ``` samples/ -├── run-all.js Cross-platform batch renderer (Node.js ≥ 20) +├── run-all.js Cross-platform batch renderer (Node.js ≥ 22) ├── render/ JSON payloads for pdfnative render │ ├── document/ General-purpose documents (06-max-blocks.* = --max-blocks guard, v1.1.0) │ ├── table/ Table-heavy layouts @@ -83,7 +83,15 @@ samples/ │ ├── outline/ (v1.2.0) PDF bookmarks — `--outline auto` + explicit tree │ ├── math/ (v1.2.0) Math/technical symbols via `--font math` │ ├── inspect-layout/ (v1.2.0) `--inspect-layout` report + `--debug-layout` guides -│ └── chart/ (v1.3.0) Native vector charts (bar/line/pie/donut) +│ ├── chart/ (v1.3.0) Native vector charts +│ │ ├── 01-bar-chart.json Multi-series bar chart with legend +│ │ ├── 02-line-and-pie.json Line chart + donut chart +│ │ ├── 03-stacked-bars.json (v1.4.0) stackedBar / stackedBarH + dataLabels +│ │ ├── 04-area-scatter.json (v1.4.0) area + dual axes (axis2) + log-scale scatter +│ │ └── 05-time-axis.json (v1.4.0) time x-axis (ISO xValues) + labelRotation +│ └── print/ (v1.4.0) Print production & viewer preferences (pdfnative 1.7.0) +│ ├── 01-bleed-marks.json (v1.4.0) layout.print — bleed, TrimBox, crop/registration marks, trapped +│ └── 02-viewer-prefs.json (v1.4.0) layout.viewerPreferences — duplex, copies, print range, tray ├── merge/ (v1.2.0) Concatenate PDFs (pdfnative page-tree) ├── split/ (v1.2.0) Split one PDF into many (per-page or per-range) ├── extract/ (v1.2.0) Pull selected pages into a new PDF @@ -92,8 +100,14 @@ samples/ ├── encrypt/ (v1.3.0) Encrypt / decrypt (AES-128/256, --password, --stream) ├── doctor/ (v1.3.0) Environment / capability preflight ├── annotate/ (v1.2.0) Attach markup annotations (incremental save) +├── metadata/ (v1.4.0) Incremental /Info + XMP metadata update (keeps signatures) +├── compare/ (v1.4.0) Text/structure diff of two PDFs (CI exit codes) ├── govern/ (v1.2.0) AI-governance / HITL: rules, policy, verify-issue -├── batch/ (v1.0.0) Parallel directory render (pdfnative batch) +├── batch/ (v1.0.0) Parallel directory render + manifest pipelines +│ ├── 01-batch.* Parallel directory render +│ ├── 02-fail-fast.* (v1.1.0) --fail-fast abort demo +│ ├── 03-manifest.* (v1.4.0) `batch --manifest` — render → encrypt → inspect pipeline +│ └── manifest/ (v1.4.0) tasks.json (@id refs) + report.json input ├── agent/ (v1.1.0) Agent-native contract: --json envelope, --dry-run, schema │ ├── 01-json-and-dry-run.* --json status envelope + --dry-run validation │ ├── 02-schema.* `schema` command — versioned JSON Schemas @@ -107,8 +121,10 @@ samples/ │ ├── 03-ecdsa.* (v0.3.0) P-256 ECDSA-SHA256 sign │ ├── 04-roundtrip.* (v0.3.0) render → sign → verify pipeline │ ├── 05-cert-chain.* (v1.1.0) Root-CA → signer chain via --cert-chain + verify --trust -│ ├── 06-timestamp-reserved.* (v1.1.0) --timestamp is reserved → exit 2 (E_UNSUPPORTED) -│ └── 07-native-crypto.* (v1.2.0) Native node:crypto (default) vs pure-JS (--pure-crypto) +│ ├── 06-timestamp.* (v1.4.0) PAdES B-T — sign --timestamp --profile pades +│ ├── 07-native-crypto.* (v1.2.0) Native node:crypto (default) vs pure-JS (--pure-crypto) +│ ├── 08-ltv.* (v1.4.0) Full PAdES ladder: B-B → B-T → B-LT → B-LTA +│ └── 09-multiple-signatures.* (v1.4.0) Two signers via --allow-multiple / --field-name ├── inspect/ PDF inspection shell / PowerShell scripts │ ├── 01-json.* JSON metadata report │ ├── 02-text.* Human-readable text report @@ -116,7 +132,8 @@ samples/ │ ├── 04-check-pdfa.* CI gate: assert PDF/A conformance │ ├── 05-pdfua.* (v1.1.0) PDF/UA (ISO 14289-1) structural validation gate │ ├── 06-check-signed-encrypted.* (v1.1.0) CI gates for --check signed / --check encrypted -│ └── 07-annotations.* (v1.2.0) List markup + link annotations (inspect --annotations) +│ ├── 07-annotations.* (v1.2.0) List markup + link annotations (inspect --annotations) +│ └── 08-list-signatures.* (v1.4.0) inspect --signatures inventory + --check "signatures>=N" ├── verify/ Signature verification shell / PowerShell scripts │ ├── 01-self-signed.* (v0.2.0) Verify a self-signed PDF │ ├── 02-strict-mode.* (v0.2.0) `--strict` exits non-zero on failure @@ -217,12 +234,22 @@ PDF/A conformance can also be set from the CLI via the `--tagged` flag (or the d ```bash # Preferred (v0.2.0+) -pdfnative render --input doc.json --output doc.pdf --tagged pdfa2b +pdfnative render --input doc.json --output doc.pdf --tagged pdfa2b --font latin --lang latin # Deprecated alias — still works, prints a stderr deprecation notice -pdfnative render --input doc.json --output doc.pdf --conformance 2b +pdfnative render --input doc.json --output doc.pdf --conformance 2b --font latin --lang latin ``` +> **PDF/A conformance:** the `--tagged pdfa*` flag only *declares* the claim — +> real conformance requires embedded fonts (ISO 19005 §6.2.11.4.1 / §6.3.4), so +> always pass `--font latin --lang latin` (`run-all.js` applies them +> automatically for the `pdfa` and `attachments` categories). Without them the +> render emits a `PDFA_NO_FONT_ENTRIES` warning and the output fails the +> reference validator. PDF/A outputs are validated with veraPDF in CI +> (blocking — conformant corpus plus negative canaries); run +> `npm run validate:pdfa` locally, but note it exits 0 as a *skip* when veraPDF +> is not installed — that is not a proof of conformance. + ### `render/encryption/` — Password Protection (v0.2.0) | File | Description | @@ -514,8 +541,11 @@ Passwords are read from `$PDFNATIVE_ENCRYPT_OWNER_PASS` / `$PDFNATIVE_ENCRYPT_US |------|-------------| | [01-bar-chart.json](render/chart/01-bar-chart.json) | Multi-series bar chart with legend | | [02-line-and-pie.json](render/chart/02-line-and-pie.json) | Line chart + donut chart | +| [03-stacked-bars.json](render/chart/03-stacked-bars.json) | (v1.4.0) `stackedBar` / `stackedBarH` with per-segment `dataLabels` (prefix/suffix/decimals) | +| [04-area-scatter.json](render/chart/04-area-scatter.json) | (v1.4.0) `area` with a secondary right axis (`series.yAxis` + `axis2`) and a `scatter` on a linear `xAxis` with a log-scale value axis | +| [05-time-axis.json](render/chart/05-time-axis.json) | (v1.4.0) `line` on a time `xAxis` (ISO 8601 `xValues`) + bar chart with `labelRotation: 45` | -Charts render as pure PDF path operators (bar, barH, line, pie, donut) — zero dependencies, no rasterisation, tagged `/Figure` with alt text. Rendered by `run-all.js` like any other document sample. +Charts render as pure PDF path operators — zero dependencies, no rasterisation, tagged `/Figure` with alt text. Charts v2 (v1.4.0, pdfnative 1.7.0) grows the family to 9 types (`bar`, `barH`, `stackedBar`, `stackedBarH`, `line`, `area`, `scatter`, `pie`, `donut`) with dual axes (`axis2`), `xAxis` `category|linear|time`, logarithmic value scale, `dataLabels`, and `labelStride`/`labelRotation`. Rendered by `run-all.js` like any other document sample. --- @@ -533,6 +563,54 @@ Annotations are attached with an incremental save, so any existing signature sta --- +## Long-term signatures & document ops Samples (v1.4.0, pdfnative 1.7.0) + +v1.4.0 lights up the sign-side LTV ladder (PAdES B-T → B-LT → B-LTA) and adds document +operations: metadata editing, PDF comparison, manifest pipelines, and print production. + +### `metadata/` — Incremental Metadata Update (v1.4.0) + +| File | Description | +|------|-------------| +| [document.json](metadata/document.json) | (v1.4.0) Source document with placeholder `/Info` metadata | +| [01-update-metadata.sh](metadata/01-update-metadata.sh) | (v1.4.0) Render → `metadata --title --author` (incremental save — existing signatures stay valid) → `inspect` | +| [01-update-metadata.ps1](metadata/01-update-metadata.ps1) | (v1.4.0) PowerShell equivalent | + +`metadata` rewrites `/Info` and keeps the XMP packet in sync (`xmp:ModifyDate`, `pdf:Keywords`, …). Pass a fixed `--mod-date` for reproducible output. Reading metadata stays in `inspect`. + +### `compare/` — Text & Structure Diff (v1.4.0) + +| File | Description | +|------|-------------| +| [document-a.json](compare/document-a.json) | (v1.4.0) Baseline contract document | +| [document-b.json](compare/document-b.json) | (v1.4.0) Near-identical contract with one changed clause | +| [01-compare.sh](compare/01-compare.sh) | (v1.4.0) Renders both, then `compare` — differences exit 1 (`E_CHECK_FAILED`), identical documents exit 0 | +| [01-compare.ps1](compare/01-compare.ps1) | (v1.4.0) PowerShell equivalent | + +`compare` diffs extracted reading-order text and/or structure (`--mode text|structure|both`, `--tolerance`, `--ignore-whitespace`, `--pages`, `--password-a`/`--password-b`). The non-zero exit on difference is the CI feature. Visual/pixel diffing is out of scope (pdfnative has no rasteriser). + +### `render/print/` — Print Production & Viewer Preferences (v1.4.0) + +| File | Description | +|------|-------------| +| [01-bleed-marks.json](render/print/01-bleed-marks.json) | (v1.4.0) `layout.print` — 9 pt bleed shorthand (derives `/TrimBox`, sets `/BleedBox`), crop + registration marks, `metadata.trapped` | +| [02-viewer-prefs.json](render/print/02-viewer-prefs.json) | (v1.4.0) `layout.viewerPreferences` — duplex, `numCopies`, `printPageRange`, `pickTrayByPDFSize` print-dialog defaults | + +Both are plain document samples rendered by `run-all.js`. `layout.print` also accepts explicit `trimBox`/`bleedBox`/`artBox`/`cropBox` and `userUnit`; an `outputIntent` (ICC RGB) can be declared alongside. + +### Network-dependent samples + +The timestamp / LTV samples ([sign/06-timestamp.*](sign/06-timestamp.sh) and +[sign/08-ltv.*](sign/08-ltv.sh)) run **offline by default**: they always perform the +offline part (render → PAdES B-B sign) and only exercise the network — through the +CLI's SSRF-guarded client — when the `PDFNATIVE_TSA_URL` environment variable points +at an RFC 3161 TSA. Without it, the network rungs are printed as explained commands. +Network access in pdfnative-cli is always an explicit opt-in (`sign --timestamp`, +`doc-timestamp --url`, `ltv --online`, `verify --revocation online`, +`batch --allow-network`). + +--- + ## Govern Samples — AI Governance / HITL (v1.2.0) ### `govern/` — Human-in-the-Loop Contract @@ -566,10 +644,14 @@ Demonstrate the `pdfnative sign` command. Both Unix shell and PowerShell scripts | [sign/04-roundtrip.ps1](sign/04-roundtrip.ps1) | (v0.3.0) PowerShell equivalent | | [sign/05-cert-chain.sh](sign/05-cert-chain.sh) | (v1.1.0) Build a root-CA → signer chain, sign with `--cert-chain`, then `verify --trust ` | | [sign/05-cert-chain.ps1](sign/05-cert-chain.ps1) | (v1.1.0) PowerShell equivalent | -| [sign/06-timestamp-reserved.sh](sign/06-timestamp-reserved.sh) | (v1.1.0) Shows `--timestamp` is reserved and exits 2 (`E_UNSUPPORTED`) — sign-side LTV is upstream-blocked | -| [sign/06-timestamp-reserved.ps1](sign/06-timestamp-reserved.ps1) | (v1.1.0) PowerShell equivalent | +| [sign/06-timestamp.sh](sign/06-timestamp.sh) | (v1.4.0) PAdES B-T — `sign --timestamp --profile pades` embeds a verified RFC 3161 token at signing time (network only when `PDFNATIVE_TSA_URL` is set) | +| [sign/06-timestamp.ps1](sign/06-timestamp.ps1) | (v1.4.0) PowerShell equivalent | | [sign/07-native-crypto.sh](sign/07-native-crypto.sh) | (v1.2.0) Signs the same PDF with native `node:crypto` (default) and pure-JS (`--pure-crypto`), verifying both | | [sign/07-native-crypto.ps1](sign/07-native-crypto.ps1) | (v1.2.0) PowerShell equivalent | +| [sign/08-ltv.sh](sign/08-ltv.sh) | (v1.4.0) The full PAdES ladder — B-B → B-T (`--timestamp`) → B-LT (`ltv add --online`) → B-LTA (`doc-timestamp --url`); offline by default | +| [sign/08-ltv.ps1](sign/08-ltv.ps1) | (v1.4.0) PowerShell equivalent | +| [sign/09-multiple-signatures.sh](sign/09-multiple-signatures.sh) | (v1.4.0) Two signers on one PDF with `--allow-multiple` / `--field-name`, inventoried with `inspect --signatures` and both verified — fully offline | +| [sign/09-multiple-signatures.ps1](sign/09-multiple-signatures.ps1) | (v1.4.0) PowerShell equivalent | **Prerequisites:** `openssl` on your PATH (ships with Git for Windows). @@ -605,6 +687,8 @@ Demonstrate the `pdfnative inspect` command. | [inspect/06-check-signed-encrypted.ps1](inspect/06-check-signed-encrypted.ps1) | (v1.1.0) PowerShell equivalent | | [inspect/07-annotations.sh](inspect/07-annotations.sh) | (v1.2.0) Render → annotate → `inspect --annotations` to list markup + link annotations | | [inspect/07-annotations.ps1](inspect/07-annotations.ps1) | (v1.2.0) PowerShell equivalent | +| [inspect/08-list-signatures.sh](inspect/08-list-signatures.sh) | (v1.4.0) `inspect --signatures` JSON inventory + `--check "signatures>=N"` CI gates (pass and clean-fail shown) — fully offline | +| [inspect/08-list-signatures.ps1](inspect/08-list-signatures.ps1) | (v1.4.0) PowerShell equivalent | --- @@ -627,7 +711,7 @@ Demonstrate the `pdfnative verify` command — verifies CMS/PKCS#7 signatures em | [verify/06-online-revocation.sh](verify/06-online-revocation.sh) | (v1.1.0) Offline-by-default verify, with a commented SSRF-guarded `--revocation online` variant | | [verify/06-online-revocation.ps1](verify/06-online-revocation.ps1) | (v1.1.0) PowerShell equivalent | -**Scope (v1.0.0):** verify checks **integrity** (byte-range SHA-256), **CMS signature value** (RSA-PKCS#1 v1.5 SHA-256 and ECDSA-SHA256 over P-256), **certificate chain signatures**, **trust** (against `--trust ` PEM roots, or self-signed acceptance), **RFC 3161 timestamp validation (PAdES-T)**, and **OCSP (RFC 6960) + CRL (RFC 5280) revocation** — embedded from the PDF `/DSS` offline by default, with opt-in SSRF-guarded online fetching via `--revocation online`. Sign-side LTV (embedding timestamps/DSS at signing time) is upstream-blocked in pdfnative — see [ROADMAP.md](../ROADMAP.md) and [SECURITY.md](../SECURITY.md#network-access--revocation-checking). +**Scope:** verify checks **integrity** (byte-range SHA-256), **CMS signature value** (RSA-PKCS#1 v1.5 — SHA-256/384/512 since v1.4.0 — and ECDSA-SHA256 over P-256), **certificate chain signatures**, **trust** (against `--trust ` PEM roots, or self-signed acceptance), **RFC 3161 timestamp validation (PAdES-T)**, and **OCSP (RFC 6960) + CRL (RFC 5280) revocation** — embedded from the PDF `/DSS` offline by default, with opt-in SSRF-guarded online fetching via `--revocation online`. Since v1.4.0 each signature also reports its `fieldName`, and `/DocTimeStamp` revisions (PAdES B-LTA) are validated as RFC 3161 tokens (`isDocTimestamp: true`). Sign-side LTV **shipped in v1.4.0** — see [sign/06-timestamp.*](sign/06-timestamp.sh), [sign/08-ltv.sh](sign/08-ltv.sh), and [SECURITY.md](../SECURITY.md#network-access--revocation-checking). --- @@ -641,8 +725,12 @@ Demonstrate the `pdfnative batch` command — renders every `*.json` in a direct | [batch/01-batch.ps1](batch/01-batch.ps1) | PowerShell equivalent | | [batch/02-fail-fast.sh](batch/02-fail-fast.sh) | (v1.1.0) `--fail-fast` aborts on the first failure (one valid + one invalid input); asserts non-zero exit | | [batch/02-fail-fast.ps1](batch/02-fail-fast.ps1) | (v1.1.0) PowerShell equivalent | +| [batch/03-manifest.sh](batch/03-manifest.sh) | (v1.4.0) `batch --manifest` — declarative render → encrypt → inspect pipeline; fully offline (network flags in a manifest require `--allow-network`) | +| [batch/03-manifest.ps1](batch/03-manifest.ps1) | (v1.4.0) PowerShell equivalent | +| [batch/manifest/tasks.json](batch/manifest/tasks.json) | (v1.4.0) The pipeline manifest (schema subject `batch-manifest`) — `"@id"` flag values reference an earlier task's output | +| [batch/manifest/report.json](batch/manifest/report.json) | (v1.4.0) Document definition rendered by the manifest's first task | -Render flags other than `--input-dir` / `--output-dir` / `--concurrency` / `--fail-fast` / `--format` are forwarded to every file. +In directory mode, render flags other than `--input-dir` / `--output-dir` / `--concurrency` / `--fail-fast` / `--format` are forwarded to every file. In manifest mode (v1.4.0), tasks run sequentially and may use 16 whitelisted commands; add `--continue-on-error` to keep going past a failure (tasks depending on it via `@` are skipped). --- @@ -814,7 +902,7 @@ Every block type accepted by `pdfnative render` is demonstrated in [render/docum | `spacer` | `height` | any document sample | | `pageBreak` | *(no fields)* | [03-all-blocks.json](render/document/03-all-blocks.json) | -> `ImageBlock` and `SvgBlock` require binary data (`Uint8Array`) and cannot be expressed in plain JSON. Use the `pdfnative` Node.js API directly for those block types. +> `SvgBlock` is fully usable from JSON (its `data` field is an SVG **string**, pdfnative ≥ 1.5.0). `ImageBlock` is JSON-usable since v1.4.0 via `src` (a JPEG/PNG path, resolved relative to the `--input` JSON's directory) or `dataBase64` (inline base64). --- diff --git a/samples/batch/03-manifest.ps1 b/samples/batch/03-manifest.ps1 new file mode 100644 index 0000000..6a813b4 --- /dev/null +++ b/samples/batch/03-manifest.ps1 @@ -0,0 +1,31 @@ +# batch/03-manifest.ps1 — run a declarative multi-command pipeline (v1.4.0) +# +# Demonstrates `batch --manifest`: a tasks.json declares an ordered pipeline +# (render → encrypt → inspect) where "@id" values reference the output of an +# earlier task. Fully offline — network-reaching flags in a manifest are +# refused unless batch is invoked with --allow-network. +# +# Usage: +# pwsh samples/batch/03-manifest.ps1 + +$ErrorActionPreference = 'Stop' +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$Manifest = Join-Path $ScriptDir 'manifest\tasks.json' +# Manifest-relative paths get the same traversal check as direct CLI flags, so +# the pipeline writes below the manifest's own directory (git-ignored). +$OutDir = Join-Path $ScriptDir 'manifest\out' + +Write-Host '-> Previewing the pipeline (nothing is executed):' +pdfnative batch --manifest $Manifest --dry-run +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +Write-Host '' +Write-Host '-> Running the manifest pipeline (render -> encrypt -> inspect):' +pdfnative batch --manifest $Manifest --format json +if ($LASTEXITCODE -ne 0) { + Write-Host " X pipeline failed with exit code $LASTEXITCODE" + exit $LASTEXITCODE +} + +Write-Host '' +Write-Host " OK PDFs written to $OutDir (exit code is 1 if any task fails)." diff --git a/samples/batch/03-manifest.sh b/samples/batch/03-manifest.sh new file mode 100644 index 0000000..967d141 --- /dev/null +++ b/samples/batch/03-manifest.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# batch/03-manifest.sh — run a declarative multi-command pipeline (v1.4.0) +# +# Demonstrates `batch --manifest`: a tasks.json declares an ordered pipeline +# (render → encrypt → inspect) where "@id" values reference the output of an +# earlier task. Fully offline — network-reaching flags in a manifest are +# refused unless batch is invoked with --allow-network. +# +# Usage: +# bash samples/batch/03-manifest.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MANIFEST="$SCRIPT_DIR/manifest/tasks.json" +# Manifest-relative paths get the same traversal check as direct CLI flags, so +# the pipeline writes below the manifest's own directory (git-ignored). +OUT_DIR="$SCRIPT_DIR/manifest/out" + +echo "→ Previewing the pipeline (nothing is executed):" +pdfnative batch --manifest "$MANIFEST" --dry-run + +echo "" +echo "→ Running the manifest pipeline (render → encrypt → inspect):" +pdfnative batch --manifest "$MANIFEST" --format json + +echo "" +echo " ✓ PDFs written to $OUT_DIR (exit code is 1 if any task fails)." diff --git a/samples/batch/manifest/report.json b/samples/batch/manifest/report.json new file mode 100644 index 0000000..17f1165 --- /dev/null +++ b/samples/batch/manifest/report.json @@ -0,0 +1,19 @@ +{ + "title": "Manifest Pipeline Demo", + "blocks": [ + { "type": "heading", "text": "Manifest Pipeline Demo", "level": 1 }, + { "type": "paragraph", "text": "This PDF was produced by `pdfnative batch --manifest`: rendered from JSON, then re-secured with AES-256 encryption, then inspected — all in one declarative pipeline." }, + { "type": "spacer", "height": 12 }, + { "type": "heading", "text": "How it works", "level": 2 }, + { "type": "list", "style": "bullet", "items": [ + "Task \"report\" renders this document to a PDF.", + "Task \"secured\" encrypts \"@report\" (the previous task's output).", + "Task \"check\" inspects \"@secured\" and reports the encryption scheme." + ]} + ], + "footerText": "pdfnative batch --manifest", + "metadata": { + "author": "pdfnative-cli samples", + "subject": "batch manifest pipeline" + } +} diff --git a/samples/batch/manifest/tasks.json b/samples/batch/manifest/tasks.json new file mode 100644 index 0000000..e340219 --- /dev/null +++ b/samples/batch/manifest/tasks.json @@ -0,0 +1,34 @@ +{ + "version": 1, + "tasks": [ + { + "id": "report", + "command": "render", + "flags": { + "input": "report.json", + "output": "out/report.pdf", + "compress": true + } + }, + { + "id": "secured", + "command": "encrypt", + "flags": { + "input": "@report", + "output": "out/report-secured.pdf", + "owner-password": "owner-secret", + "algorithm": "aes-256", + "permissions": "print,copy" + } + }, + { + "id": "check", + "command": "inspect", + "flags": { + "input": "@secured", + "password": "owner-secret", + "encryption": true + } + } + ] +} diff --git a/samples/compare/01-compare.ps1 b/samples/compare/01-compare.ps1 new file mode 100644 index 0000000..556880d --- /dev/null +++ b/samples/compare/01-compare.ps1 @@ -0,0 +1,45 @@ +# compare/01-compare.ps1 — text/structure diff of two PDFs (pdfnative-cli 1.4.0) +# +# Renders two near-identical contracts (one clause changed), then compares +# them. `compare` exits 1 (E_CHECK_FAILED) when differences are found — that +# non-zero exit is the FEATURE (CI-friendly), so this script checks +# $LASTEXITCODE explicitly. A visual/pixel diff is out of scope: compare works +# on text and structure, never rendered pixels. +# +# Usage: +# pwsh -File samples\compare\01-compare.ps1 + +$ErrorActionPreference = 'Stop' + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RootDir = Split-Path -Parent (Split-Path -Parent $ScriptDir) +$OutputDir = Join-Path $RootDir 'samples\output\compare' + +New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null + +Write-Host '→ [1/4] Rendering version A…' +& pdfnative render ` + --input (Join-Path $RootDir 'samples\compare\document-a.json') ` + --output (Join-Path $OutputDir 'contract-a.pdf') + +Write-Host '→ [2/4] Rendering version B (one clause changed)…' +& pdfnative render ` + --input (Join-Path $RootDir 'samples\compare\document-b.json') ` + --output (Join-Path $OutputDir 'contract-b.pdf') + +Write-Host '→ [3/4] Comparing A vs B (differences expected — exit 1 is the signal):' +& pdfnative compare (Join-Path $OutputDir 'contract-a.pdf') (Join-Path $OutputDir 'contract-b.pdf') +if ($LASTEXITCODE -ne 0) { + Write-Host " ✓ compare exited $LASTEXITCODE — differences detected, as expected for CI gating" +} else { + Write-Host ' ✗ unexpected: the documents were reported identical' + exit 1 +} + +Write-Host '→ [4/4] Comparing A vs A (identical — exit 0):' +& pdfnative compare (Join-Path $OutputDir 'contract-a.pdf') (Join-Path $OutputDir 'contract-a.pdf') +if ($LASTEXITCODE -ne 0) { + Write-Host " ✗ unexpected: identical documents exited $LASTEXITCODE" + exit 1 +} +Write-Host ' ✓ identical documents exit 0' diff --git a/samples/compare/01-compare.sh b/samples/compare/01-compare.sh new file mode 100644 index 0000000..ffa577a --- /dev/null +++ b/samples/compare/01-compare.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# compare/01-compare.sh — text/structure diff of two PDFs (pdfnative-cli 1.4.0) +# +# Renders two near-identical contracts (one clause changed), then compares +# them. `compare` exits 1 (E_CHECK_FAILED) when differences are found — that +# non-zero exit is the FEATURE (CI-friendly), so this script handles it +# explicitly instead of letting `set -e` abort. A visual/pixel diff is out of +# scope: compare works on text and structure, never rendered pixels. +# +# Prerequisites: +# - pdfnative-cli installed globally: npm install -g pdfnative-cli +# +# Usage: +# bash samples/compare/01-compare.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +OUTPUT_DIR="$ROOT_DIR/samples/output/compare" + +mkdir -p "$OUTPUT_DIR" + +echo "→ [1/4] Rendering version A…" +pdfnative render \ + --input "$ROOT_DIR/samples/compare/document-a.json" \ + --output "$OUTPUT_DIR/contract-a.pdf" + +echo "→ [2/4] Rendering version B (one clause changed)…" +pdfnative render \ + --input "$ROOT_DIR/samples/compare/document-b.json" \ + --output "$OUTPUT_DIR/contract-b.pdf" + +echo "→ [3/4] Comparing A vs B (differences expected — exit 1 is the signal):" +if pdfnative compare "$OUTPUT_DIR/contract-a.pdf" "$OUTPUT_DIR/contract-b.pdf"; then + echo " ✗ unexpected: the documents were reported identical" + exit 1 +else + echo " ✓ compare exited $? — differences detected, as expected for CI gating" +fi + +echo "→ [4/4] Comparing A vs A (identical — exit 0):" +pdfnative compare "$OUTPUT_DIR/contract-a.pdf" "$OUTPUT_DIR/contract-a.pdf" +echo " ✓ identical documents exit 0" diff --git a/samples/compare/document-a.json b/samples/compare/document-a.json new file mode 100644 index 0000000..69831d8 --- /dev/null +++ b/samples/compare/document-a.json @@ -0,0 +1,7 @@ +{ + "blocks": [ + { "type": "heading", "text": "Contract — Draft", "level": 1 }, + { "type": "paragraph", "text": "1. The delivery deadline is 30 days after signature." }, + { "type": "paragraph", "text": "2. The total fee is 10,000 EUR, payable within 30 days." } + ] +} diff --git a/samples/compare/document-b.json b/samples/compare/document-b.json new file mode 100644 index 0000000..e69972f --- /dev/null +++ b/samples/compare/document-b.json @@ -0,0 +1,7 @@ +{ + "blocks": [ + { "type": "heading", "text": "Contract — Draft", "level": 1 }, + { "type": "paragraph", "text": "1. The delivery deadline is 45 days after signature." }, + { "type": "paragraph", "text": "2. The total fee is 10,000 EUR, payable within 30 days." } + ] +} diff --git a/samples/inspect/08-list-signatures.ps1 b/samples/inspect/08-list-signatures.ps1 new file mode 100644 index 0000000..2bc1c5f --- /dev/null +++ b/samples/inspect/08-list-signatures.ps1 @@ -0,0 +1,82 @@ +# inspect/08-list-signatures.ps1 — signature inventory + "signatures>=N" gates +# +# pdfnative-cli 1.4.0 — `inspect --signatures` lists every signature form +# field (fieldName, subFilter, byteRange, isDocTimestamp, isPlaceholder — +# never the signature bytes), and `--check "signatures>=N"` turns the count +# into a CI gate (exit 0 when satisfied, exit 1 otherwise). 100% offline. +# +# Prerequisites: +# - pdfnative-cli installed globally: npm install -g pdfnative-cli +# - openssl available on your PATH +# +# Usage: +# pwsh -File samples\inspect\08-list-signatures.ps1 +# +# Output: samples\output\inspect\08-signed.pdf + +$ErrorActionPreference = 'Stop' + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RootDir = Split-Path -Parent (Split-Path -Parent $ScriptDir) +$OutputDir = Join-Path $RootDir 'samples\output\inspect' +$KeysDir = Join-Path $OutputDir 'keys' + +New-Item -ItemType Directory -Force -Path $OutputDir, $KeysDir | Out-Null + +$PlainPdf = Join-Path $OutputDir '08-plain.pdf' +$SignedPdf = Join-Path $OutputDir '08-signed.pdf' +$KeyFile = Join-Path $KeysDir '08-signing.key' +$CertFile = Join-Path $KeysDir '08-signing.crt' + +# ── Step 1: render a document ────────────────────────────────────────────── +Write-Host '→ [1/4] Rendering source document…' +& pdfnative render ` + --input (Join-Path $RootDir 'samples\render\document\01-minimal.json') ` + --output $PlainPdf +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +Write-Host " ✓ Rendered: $PlainPdf" + +# ── Step 2: sign it with a throwaway self-signed certificate ─────────────── +Write-Host '→ [2/4] Signing with a self-signed demo certificate…' +if (-not (Test-Path $KeyFile) -or -not (Test-Path $CertFile)) { + & openssl req -x509 -newkey rsa:2048 -keyout $KeyFile -out $CertFile ` + -days 365 -nodes ` + -subj '/CN=pdfnative Demo/O=pdfnative/C=US' 2>$null + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +} +& pdfnative sign ` + --input $PlainPdf ` + --output $SignedPdf ` + --key $KeyFile ` + --cert $CertFile +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +Write-Host " ✓ Signed: $SignedPdf" + +# ── Step 3: list the signature fields (JSON inventory) ───────────────────── +Write-Host '→ [3/4] inspect --signatures (JSON inventory):' +& pdfnative inspect ` + --input $SignedPdf ` + --signatures ` + --format json ` + --fields signatures ` + --pretty +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# ── Step 4: count gates — signatures>=1 passes, signatures>=2 fails ──────── +Write-Host '→ [4/4] --check "signatures>=1" (expected PASS):' +& pdfnative inspect --input $SignedPdf --check 'signatures>=1' --format text | Out-Null +if ($LASTEXITCODE -ne 0) { Write-Host ' ✗ UNEXPECTED — gate failed.'; exit 1 } +Write-Host " ✓ exit code $LASTEXITCODE — the document carries at least one signature." + +Write-Host '→ --check "signatures>=2" (expected FAIL — shown pedagogically):' +& pdfnative inspect --input $SignedPdf --check 'signatures>=2' --format text | Out-Null +if ($LASTEXITCODE -ne 0) { + Write-Host ' ✓ the gate exits 1 when the count is not reached — the failure is' + Write-Host ' absorbed here for the demo; in CI you would let it fail the job.' +} else { + Write-Host ' ✗ UNEXPECTED — the >=2 gate passed on a once-signed document.'; exit 1 +} + +Write-Host '' +Write-Host 'Expect: one signature entry in the inventory; the >=1 gate passes (exit 0)' +Write-Host 'and the >=2 gate fails cleanly (exit 1).' diff --git a/samples/inspect/08-list-signatures.sh b/samples/inspect/08-list-signatures.sh new file mode 100644 index 0000000..bdfe7bb --- /dev/null +++ b/samples/inspect/08-list-signatures.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# inspect/08-list-signatures.sh — signature inventory + "signatures>=N" gates +# +# pdfnative-cli 1.4.0 — `inspect --signatures` lists every signature form +# field (fieldName, subFilter, byteRange, isDocTimestamp, isPlaceholder — +# never the signature bytes), and `--check "signatures>=N"` turns the count +# into a CI gate (exit 0 when satisfied, exit 1 otherwise). 100% offline. +# +# Prerequisites: +# - pdfnative-cli installed globally: npm install -g pdfnative-cli +# - openssl available on your PATH +# +# Usage: +# bash samples/inspect/08-list-signatures.sh +# +# Output: samples/output/inspect/08-signed.pdf + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +OUTPUT_DIR="$ROOT_DIR/samples/output/inspect" +KEYS_DIR="$OUTPUT_DIR/keys" + +mkdir -p "$OUTPUT_DIR" "$KEYS_DIR" + +PLAIN_PDF="$OUTPUT_DIR/08-plain.pdf" +SIGNED_PDF="$OUTPUT_DIR/08-signed.pdf" +KEY_FILE="$KEYS_DIR/08-signing.key" +CERT_FILE="$KEYS_DIR/08-signing.crt" + +# ── Step 1: render a document ────────────────────────────────────────────── +echo "→ [1/4] Rendering source document…" +pdfnative render \ + --input "$ROOT_DIR/samples/render/document/01-minimal.json" \ + --output "$PLAIN_PDF" +echo " ✓ Rendered: $PLAIN_PDF" + +# ── Step 2: sign it with a throwaway self-signed certificate ─────────────── +echo "→ [2/4] Signing with a self-signed demo certificate…" +if [ ! -f "$KEY_FILE" ] || [ ! -f "$CERT_FILE" ]; then + openssl req -x509 -newkey rsa:2048 -keyout "$KEY_FILE" -out "$CERT_FILE" \ + -days 365 -nodes \ + -subj "/CN=pdfnative Demo/O=pdfnative/C=US" 2>/dev/null +fi +pdfnative sign \ + --input "$PLAIN_PDF" \ + --output "$SIGNED_PDF" \ + --key "$KEY_FILE" \ + --cert "$CERT_FILE" +echo " ✓ Signed: $SIGNED_PDF" + +# ── Step 3: list the signature fields (JSON inventory) ───────────────────── +echo "→ [3/4] inspect --signatures (JSON inventory):" +pdfnative inspect \ + --input "$SIGNED_PDF" \ + --signatures \ + --format json \ + --fields signatures \ + --pretty + +# ── Step 4: count gates — signatures>=1 passes, signatures>=2 fails ──────── +echo "→ [4/4] --check \"signatures>=1\" (expected PASS):" +pdfnative inspect --input "$SIGNED_PDF" --check "signatures>=1" --format text >/dev/null +echo " ✓ exit code $? — the document carries at least one signature." + +echo "→ --check \"signatures>=2\" (expected FAIL — shown pedagogically):" +pdfnative inspect --input "$SIGNED_PDF" --check "signatures>=2" --format text >/dev/null || true +echo " ✓ the gate exits 1 when the count is not reached — '|| true' keeps this" +echo " demo script alive; in CI you would let the non-zero exit fail the job." + +echo "" +echo "Expect: one signature entry in the inventory; the >=1 gate passes (exit 0)" +echo "and the >=2 gate fails cleanly (exit 1)." diff --git a/samples/metadata/01-update-metadata.ps1 b/samples/metadata/01-update-metadata.ps1 new file mode 100644 index 0000000..9787a94 --- /dev/null +++ b/samples/metadata/01-update-metadata.ps1 @@ -0,0 +1,33 @@ +# metadata/01-update-metadata.ps1 — incremental metadata update (pdfnative 1.7.0) +# +# Renders a document, rewrites its /Info metadata (title + author) with an +# INCREMENTAL save — the original bytes are preserved, so any existing +# signature stays valid for its revision — then inspects the result. +# +# Usage: +# pwsh -File samples\metadata\01-update-metadata.ps1 + +$ErrorActionPreference = 'Stop' + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RootDir = Split-Path -Parent (Split-Path -Parent $ScriptDir) +$OutputDir = Join-Path $RootDir 'samples\output\metadata' + +New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null + +Write-Host '→ [1/3] Rendering the source document…' +& pdfnative render ` + --input (Join-Path $RootDir 'samples\metadata\document.json') ` + --output (Join-Path $OutputDir 'source.pdf') + +Write-Host '→ [2/3] Updating title + author (incremental — original bytes preserved)…' +& pdfnative metadata ` + --input (Join-Path $OutputDir 'source.pdf') ` + --output (Join-Path $OutputDir 'updated.pdf') ` + --title 'Quarterly Report — FY2026' ` + --author 'Finance Team' ` + --mod-date '2026-01-15T00:00:00Z' + +Write-Host '→ [3/3] Inspecting the updated document:' +& pdfnative inspect --input (Join-Path $OutputDir 'updated.pdf') --format text +Write-Host " ✓ Wrote $OutputDir\updated.pdf" diff --git a/samples/metadata/01-update-metadata.sh b/samples/metadata/01-update-metadata.sh new file mode 100644 index 0000000..143a331 --- /dev/null +++ b/samples/metadata/01-update-metadata.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# metadata/01-update-metadata.sh — incremental metadata update (pdfnative 1.7.0) +# +# Renders a document, rewrites its /Info metadata (title + author) with an +# INCREMENTAL save — the original bytes are preserved, so any existing +# signature stays valid for its revision — then inspects the result. +# +# Prerequisites: +# - pdfnative-cli installed globally: npm install -g pdfnative-cli +# +# Usage: +# bash samples/metadata/01-update-metadata.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +OUTPUT_DIR="$ROOT_DIR/samples/output/metadata" + +mkdir -p "$OUTPUT_DIR" + +echo "→ [1/3] Rendering the source document…" +pdfnative render \ + --input "$ROOT_DIR/samples/metadata/document.json" \ + --output "$OUTPUT_DIR/source.pdf" + +echo "→ [2/3] Updating title + author (incremental — original bytes preserved)…" +pdfnative metadata \ + --input "$OUTPUT_DIR/source.pdf" \ + --output "$OUTPUT_DIR/updated.pdf" \ + --title "Quarterly Report — FY2026" \ + --author "Finance Team" \ + --mod-date "2026-01-15T00:00:00Z" + +echo "→ [3/3] Inspecting the updated document:" +pdfnative inspect --input "$OUTPUT_DIR/updated.pdf" --format text +echo " ✓ Wrote $OUTPUT_DIR/updated.pdf" diff --git a/samples/metadata/document.json b/samples/metadata/document.json new file mode 100644 index 0000000..9baa44b --- /dev/null +++ b/samples/metadata/document.json @@ -0,0 +1,7 @@ +{ + "blocks": [ + { "type": "heading", "text": "Quarterly Report", "level": 1 }, + { "type": "paragraph", "text": "This document is rendered once, then its metadata is updated with an INCREMENTAL save: the original bytes are preserved, so existing signatures stay intact." }, + { "type": "paragraph", "text": "Compare the inspect output before and after to see the /Info change." } + ] +} diff --git a/samples/render/chart/03-stacked-bars.json b/samples/render/chart/03-stacked-bars.json new file mode 100644 index 0000000..2cadebd --- /dev/null +++ b/samples/render/chart/03-stacked-bars.json @@ -0,0 +1,33 @@ +{ + "blocks": [ + { "type": "heading", "text": "Stacked Bars (charts v2)", "level": 1 }, + { "type": "paragraph", "text": "stackedBar / stackedBarH stack every series into one bar per category; dataLabels annotate each segment with its value — here formatted with a currency prefix and a millions suffix." }, + { + "type": "chart", + "chartType": "stackedBar", + "title": "Revenue composition by quarter (USD millions)", + "categories": ["Q1", "Q2", "Q3", "Q4"], + "legend": "bottom", + "dataLabels": { "prefix": "$", "suffix": "M", "decimals": 0 }, + "series": [ + { "label": "Licences", "values": [18, 22, 25, 31] }, + { "label": "Services", "values": [9, 11, 10, 14] }, + { "label": "Support", "values": [6, 7, 8, 8] } + ] + }, + { "type": "spacer", "height": 18 }, + { + "type": "chart", + "chartType": "stackedBarH", + "title": "Headcount by office (stacked horizontal)", + "categories": ["Paris", "Berlin", "Lisbon"], + "legend": "bottom", + "dataLabels": true, + "series": [ + { "label": "Engineering", "values": [42, 28, 17] }, + { "label": "Sales", "values": [15, 12, 6] }, + { "label": "Ops", "values": [8, 5, 4] } + ] + } + ] +} diff --git a/samples/render/chart/04-area-scatter.json b/samples/render/chart/04-area-scatter.json new file mode 100644 index 0000000..059c1a7 --- /dev/null +++ b/samples/render/chart/04-area-scatter.json @@ -0,0 +1,33 @@ +{ + "blocks": [ + { "type": "heading", "text": "Area, Dual Axes & Log-Scale Scatter (charts v2)", "level": 1 }, + { "type": "paragraph", "text": "The area chart binds one series to a secondary right axis (series.yAxis: 'right' + block.axis2); the scatter chart positions points by numeric xValues on a linear x-axis with a logarithmic value axis (log requires strictly positive values)." }, + { + "type": "chart", + "chartType": "area", + "title": "Traffic vs error rate", + "categories": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], + "legend": "bottom", + "axis": { "grid": true }, + "axis2": { "yMin": 0, "yMax": 5, "ticks": 5 }, + "series": [ + { "label": "Requests (k)", "values": [120, 132, 128, 141, 155, 96, 88] }, + { "label": "Errors (%)", "values": [0.8, 1.1, 0.9, 2.4, 1.2, 0.7, 0.6], "yAxis": "right" } + ] + }, + { "type": "spacer", "height": 18 }, + { + "type": "chart", + "chartType": "scatter", + "title": "Response time vs payload size (log y-axis)", + "legend": "bottom", + "xAxis": { "type": "linear", "min": 0, "max": 1000, "grid": true }, + "axis": { "scale": "log" }, + "markers": true, + "series": [ + { "label": "p50 (ms)", "values": [12, 18, 35, 80, 210], "xValues": [10, 50, 150, 400, 900] }, + { "label": "p99 (ms)", "values": [45, 90, 180, 520, 1400], "xValues": [10, 50, 150, 400, 900] } + ] + } + ] +} diff --git a/samples/render/chart/05-time-axis.json b/samples/render/chart/05-time-axis.json new file mode 100644 index 0000000..2716979 --- /dev/null +++ b/samples/render/chart/05-time-axis.json @@ -0,0 +1,32 @@ +{ + "blocks": [ + { "type": "heading", "text": "Time X-Axis & Label Rotation (charts v2)", "level": 1 }, + { "type": "paragraph", "text": "A line chart on a time x-axis positions points by ISO-8601 xValues (tick labels are formatted in UTC), and a bar chart rotates its long category labels 45 degrees counter-clockwise so they stay readable without striding." }, + { + "type": "chart", + "chartType": "line", + "title": "Monthly active users, 2026", + "legend": "bottom", + "markers": true, + "xAxis": { "type": "time", "grid": true }, + "series": [ + { + "label": "MAU (k)", + "values": [41, 45, 52, 58, 63, 71], + "xValues": ["2026-01-01", "2026-02-01", "2026-03-01", "2026-04-01", "2026-05-01", "2026-06-01"] + } + ] + }, + { "type": "spacer", "height": 18 }, + { + "type": "chart", + "chartType": "bar", + "title": "Sign-ups by acquisition channel", + "labelRotation": 45, + "categories": ["Organic search", "Paid social campaigns", "Developer newsletter", "Conference booth leads", "Partner referrals"], + "series": [ + { "label": "2026 H1", "values": [860, 410, 275, 130, 190] } + ] + } + ] +} diff --git a/samples/render/pdfa/01-pdfa-1b.json b/samples/render/pdfa/01-pdfa-1b.json index a21cdf3..5eacae9 100644 --- a/samples/render/pdfa/01-pdfa-1b.json +++ b/samples/render/pdfa/01-pdfa-1b.json @@ -6,17 +6,17 @@ { "type": "spacer", "height": 12 }, { "type": "heading", "text": "What PDF/A-1b Guarantees", "level": 2 }, { "type": "list", "style": "bullet", "items": [ - "All fonts are fully embedded in the file.", + "All fonts must be fully embedded in the file (this sample embeds the bundled Latin font via --font latin --lang latin).", "No external content or encrypted streams.", "Colour spaces are device-independent (ICC-based).", "PDF version: 1.4." ]}, { "type": "spacer", "height": 12 }, { "type": "heading", "text": "How to Generate", "level": 2 }, - { "type": "paragraph", "text": "Use the CLI --conformance 1b flag, or set layout.tagged to 'pdfa1b' in the JSON input." }, + { "type": "paragraph", "text": "Use the CLI --tagged pdfa1b flag (or set layout.tagged to 'pdfa1b' in the JSON input), together with --font latin --lang latin: the PDF/A flag only declares the claim, while --font/--lang is what actually embeds the fonts the standard requires." }, { "type": "list", "style": "numbered", "items": [ - "CLI: pdfnative render --input doc.json --output archived.pdf --conformance 1b", - "JSON: set \\\"layout\\\": { \\\"tagged\\\": \\\"pdfa1b\\\" } in your DocumentParams" + "CLI: pdfnative render --input doc.json --output archived.pdf --tagged pdfa1b --font latin --lang latin", + "JSON: set \\\"layout\\\": { \\\"tagged\\\": \\\"pdfa1b\\\" } in your DocumentParams (still pass --font latin --lang latin on the CLI)" ]} ], "footerText": "Archived — PDF/A-1b — ISO 19005-1", diff --git a/samples/render/print/01-bleed-marks.json b/samples/render/print/01-bleed-marks.json new file mode 100644 index 0000000..c777f2e --- /dev/null +++ b/samples/render/print/01-bleed-marks.json @@ -0,0 +1,28 @@ +{ + "title": "Print Production — Bleed & Printer's Marks", + "metadata": { + "author": "pdfnative-cli samples", + "subject": "Professional print production: bleed, TrimBox/BleedBox and crop/registration marks", + "keywords": "print, bleed, trim, marks, trapped", + "trapped": "True" + }, + "layout": { + "print": { + "bleed": 9, + "marks": true + } + }, + "blocks": [ + { "type": "heading", "text": "Press-Ready A4 Flyer", "level": 1 }, + { "type": "paragraph", "text": "This document is built with layout.print (pdfnative 1.7.0): a 9 pt bleed shorthand derives the /TrimBox (the finished page after cutting) and sets the /BleedBox to the full MediaBox, while marks: true draws crop marks in the corners and registration targets on the edge midpoints — strictly outside the TrimBox." }, + { "type": "heading", "text": "How the bleed shorthand works", "level": 2 }, + { "type": "list", "items": [ + "Design the page at trim size + 2 x bleed and let backgrounds run to the page edge.", + "bleed: 9 insets the TrimBox by 9 pt on every side (3 mm is about 8.5 pt).", + "The printer cuts along the TrimBox; anything between TrimBox and page edge is trimmed away.", + "marks: true adds professional crop + registration marks for press alignment." + ] }, + { "type": "heading", "text": "Trapping flag", "level": 2 }, + { "type": "paragraph", "text": "metadata.trapped: \"True\" records in /Info /Trapped (ISO 32000-1 section 14.11.6) that this document has been trapped for high-end colour printing; the flag is mirrored to XMP as pdf:Trapped under tagged/PDF-A output." } + ] +} diff --git a/samples/render/print/02-viewer-prefs.json b/samples/render/print/02-viewer-prefs.json new file mode 100644 index 0000000..4dd121c --- /dev/null +++ b/samples/render/print/02-viewer-prefs.json @@ -0,0 +1,24 @@ +{ + "title": "Print Dialog Defaults — Duplex, Copies & Page Range", + "layout": { + "viewerPreferences": { + "duplex": "duplexFlipLongEdge", + "numCopies": 2, + "printPageRange": [[1, 2]], + "pickTrayByPDFSize": true + } + }, + "blocks": [ + { "type": "heading", "text": "Office Handout — Print Defaults Preset", "level": 1 }, + { "type": "paragraph", "text": "This document carries layout.viewerPreferences (pdfnative 1.7.0 print-dialog keys). Open the print dialog in a conforming viewer and the defaults below are pre-selected — purely presentational, PDF/A-safe hints the user can still override." }, + { "type": "table", "headers": ["Preference", "Value", "PDF key"], "rows": [ + { "cells": ["Paper handling", "Double-sided, flip on long edge", "/Duplex /DuplexFlipLongEdge"], "type": "normal", "pointed": false }, + { "cells": ["Copies", "2", "/NumCopies 2"], "type": "normal", "pointed": false }, + { "cells": ["Page range", "Pages 1-2", "/PrintPageRange [0 1]"], "type": "normal", "pointed": false }, + { "cells": ["Input tray", "Pick by PDF page size", "/PickTrayByPDFSize true"], "type": "normal", "pointed": false } + ] }, + { "type": "pageBreak" }, + { "type": "heading", "text": "Page 2 — included in the default print range", "level": 2 }, + { "type": "paragraph", "text": "The /PrintPageRange entry uses inclusive 1-based pairs in the JSON ([[1, 2]] here) and is written 0-based to the PDF. Viewers honour /NumCopies values from 2 to 5 (ISO 32000-1 Table 150); other values are ignored." } + ] +} diff --git a/samples/run-all.js b/samples/run-all.js index 51f9123..bb34f60 100644 --- a/samples/run-all.js +++ b/samples/run-all.js @@ -34,8 +34,14 @@ const CATEGORY_FLAGS = { '--footer-center', 'Page {page} of {pages}', ], encryption: [], // file-name-driven (see FILE_FLAGS): aes128 vs aes256 per file. + // pdfa / attachments: PDF/A conformance requires embedded fonts (ISO 19005 + // §6.2.11.4.1 / §6.3.4) — --font latin --lang latin embeds the bundled Latin + // font; without it the claim fails veraPDF (PDFA_NO_FONT_ENTRIES). + pdfa: ['--font', 'latin', '--lang', 'latin'], attachments: [ '--tagged', 'pdfa3b', + '--font', 'latin', + '--lang', 'latin', '--attachment', join(__dirname, 'render', 'attachments', 'invoice.xml') + ':application/xml:Source:Structured invoice payload', ], @@ -176,6 +182,14 @@ for (const job of jobs) { if (result.status === 0) { process.stdout.write('✓\n'); + // Surface warnings (e.g. PDFA_*) emitted on stderr even when the render + // succeeds — a silent warning is how a non-conformant "PDF/A" slips out. + const warn = (result.stderr ?? '').trim(); + if (warn) { + for (const line of warn.split(/\r?\n/)) { + process.stderr.write(` ${line}\n`); + } + } passed++; } else { process.stdout.write('✗\n'); @@ -237,6 +251,13 @@ if (driverJobs.length > 0) { if (result.status === 0) { process.stdout.write('✓\n'); + // Same as the render phase: surface non-fatal stderr warnings. + const warn = (result.stderr ?? '').trim(); + if (warn) { + for (const line of warn.split(/\r?\n/)) { + process.stderr.write(` ${line}\n`); + } + } driverPassed++; } else { process.stdout.write('✗\n'); diff --git a/samples/sign/06-timestamp-reserved.ps1 b/samples/sign/06-timestamp-reserved.ps1 deleted file mode 100644 index 2daa2c6..0000000 --- a/samples/sign/06-timestamp-reserved.ps1 +++ /dev/null @@ -1,27 +0,0 @@ -# sign/06-timestamp-reserved.ps1 — the reserved --timestamp flag (PAdES-T) -# -# Sign-side RFC 3161 timestamping is intentionally NOT yet available. The CLI -# surfaces the flag so the contract is discoverable, but it fails fast with a -# clear message and exit code 2 rather than silently dropping the timestamp. -# This sample asserts that contract — it expects the command to FAIL. -# -# Usage: -# pwsh -File samples\sign\06-timestamp-reserved.ps1 - -$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path -$RootDir = Split-Path -Parent (Split-Path -Parent $ScriptDir) - -Write-Host '→ Attempting sign --timestamp (expected to fail)…' -& pdfnative sign ` - --input (Join-Path $RootDir 'samples\render\document\01-minimal.json') ` - --timestamp 'http://timestamp.example/tsa' ` - --json -$status = $LASTEXITCODE - -Write-Host " exit code: $status (expected 2)" -if ($status -eq 2) { - Write-Host ' ✓ PASS — reserved flag rejected as documented.' -} else { - Write-Host ' ✗ UNEXPECTED — flag did not fail with exit 2.' - exit 1 -} diff --git a/samples/sign/06-timestamp-reserved.sh b/samples/sign/06-timestamp-reserved.sh deleted file mode 100644 index f9f139a..0000000 --- a/samples/sign/06-timestamp-reserved.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env bash -# sign/06-timestamp-reserved.sh — the reserved --timestamp flag (PAdES-T) -# -# Sign-side RFC 3161 timestamping is intentionally NOT yet available: embedding -# a timestamp token at signing time needs upstream support in pdfnative. The -# CLI surfaces the flag so the contract is discoverable, but it fails fast with -# a clear message and exit code 2 (E_UNSUPPORTED) rather than silently dropping -# the timestamp. Timestamp VALIDATION is already supported by `pdfnative verify`. -# -# This sample asserts that contract — it expects the command to FAIL. -# -# Usage: -# bash samples/sign/06-timestamp-reserved.sh - -set -uo pipefail # note: not -e; we expect a non-zero exit below - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" - -echo "→ Attempting sign --timestamp (expected to fail)…" -set +e -pdfnative sign \ - --input "$ROOT_DIR/samples/render/document/01-minimal.json" \ - --timestamp "http://timestamp.example/tsa" \ - --json 2>/tmp/pdfnative-ts.err -STATUS=$? -set -e - -echo " exit code: $STATUS (expected 2)" -echo " stderr envelope:" -sed 's/^/ /' /tmp/pdfnative-ts.err - -if [ "$STATUS" -eq 2 ]; then - echo " ✓ PASS — reserved flag rejected as documented." -else - echo " ✗ UNEXPECTED — flag did not fail with exit 2." - exit 1 -fi diff --git a/samples/sign/06-timestamp.ps1 b/samples/sign/06-timestamp.ps1 new file mode 100644 index 0000000..f3a55b3 --- /dev/null +++ b/samples/sign/06-timestamp.ps1 @@ -0,0 +1,105 @@ +# sign/06-timestamp.ps1 — PAdES B-T: sign with an RFC 3161 trusted timestamp +# +# pdfnative-cli 1.4.0 — `sign --timestamp ` embeds a verified RFC 3161 +# timestamp token in the CMS unsigned attributes at signing time; combined +# with `--profile pades` this produces a PAdES B-T signature. +# +# Network is strictly OPT-IN. The offline part (render → PAdES B-B sign) always +# runs; the TSA request only happens when PDFNATIVE_TSA_URL is set. +# +# Prerequisites: +# - pdfnative-cli installed globally: npm install -g pdfnative-cli +# - openssl available on your PATH +# - optional: PDFNATIVE_TSA_URL (e.g. http://timestamp.digicert.com) +# +# Usage: +# pwsh -File samples\sign\06-timestamp.ps1 +# $env:PDFNATIVE_TSA_URL='http://timestamp.digicert.com'; pwsh -File samples\sign\06-timestamp.ps1 +# +# Output: samples\output\sign\06-timestamp-pades-b.pdf +# samples\output\sign\06-timestamp-pades-t.pdf (network mode only) + +$ErrorActionPreference = 'Stop' + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RootDir = Split-Path -Parent (Split-Path -Parent $ScriptDir) +$OutputDir = Join-Path $RootDir 'samples\output' +$SignOut = Join-Path $OutputDir 'sign' +$KeysDir = Join-Path $SignOut 'keys' + +New-Item -ItemType Directory -Force -Path $SignOut, $KeysDir | Out-Null + +$UnsignedPdf = Join-Path $SignOut '06-timestamp-source.pdf' +$PadesBPdf = Join-Path $SignOut '06-timestamp-pades-b.pdf' +$PadesTPdf = Join-Path $SignOut '06-timestamp-pades-t.pdf' +$KeyFile = Join-Path $KeysDir 'signing.key' +$CertFile = Join-Path $KeysDir 'signing.crt' + +# ── Step 1: render the source document ───────────────────────────────────── +Write-Host '→ [1/4] Rendering source document…' +& pdfnative render ` + --input (Join-Path $RootDir 'samples\render\document\01-minimal.json') ` + --output $UnsignedPdf +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +Write-Host " ✓ Rendered: $UnsignedPdf" + +# ── Step 2: generate self-signed certificate (for demo only) ─────────────── +if (-not (Test-Path $KeyFile) -or -not (Test-Path $CertFile)) { + Write-Host '→ [2/4] Generating self-signed certificate (demo)…' + & openssl req -x509 -newkey rsa:2048 -keyout $KeyFile -out $CertFile ` + -days 365 -nodes ` + -subj '/CN=pdfnative Demo/O=pdfnative/C=US' 2>$null + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Write-Host " ✓ Key: $KeyFile" + Write-Host " ✓ Cert: $CertFile" +} else { + Write-Host "→ [2/4] Reusing demo certificate: $CertFile" +} + +# ── Step 3: offline PAdES B-B signature (no network) ─────────────────────── +Write-Host '→ [3/4] Signing offline with --profile pades (PAdES B-B)…' +& pdfnative sign ` + --input $UnsignedPdf ` + --output $PadesBPdf ` + --key $KeyFile ` + --cert $CertFile ` + --profile pades ` + --reason 'PAdES B-B baseline signature' +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +Write-Host " ✓ Signed: $PadesBPdf" + +# ── Step 4: PAdES B-T — add an RFC 3161 timestamp (opt-in network) ───────── +if ($env:PDFNATIVE_TSA_URL) { + Write-Host "→ [4/4] Signing with --timestamp against $($env:PDFNATIVE_TSA_URL) (PAdES B-T)…" + & pdfnative sign ` + --input $UnsignedPdf ` + --output $PadesTPdf ` + --key $KeyFile ` + --cert $CertFile ` + --profile pades ` + --timestamp $env:PDFNATIVE_TSA_URL ` + --reason 'PAdES B-T timestamped signature' + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Write-Host " ✓ Signed with timestamp: $PadesTPdf" + + Write-Host '→ Verifying — look for the RFC 3161 timestamp (timestampPresent)…' + & pdfnative verify ` + --input $PadesTPdf ` + --format text + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +} else { + Write-Host '→ [4/4] [skipped] network step — set PDFNATIVE_TSA_URL to run against a real TSA (e.g. http://timestamp.digicert.com)' + Write-Host ' Command that would run:' + Write-Host " pdfnative sign ``" + Write-Host " --input `"$UnsignedPdf`" ``" + Write-Host " --output `"$PadesTPdf`" ``" + Write-Host " --key `"$KeyFile`" ``" + Write-Host " --cert `"$CertFile`" ``" + Write-Host " --profile pades ``" + Write-Host ' --timestamp $env:PDFNATIVE_TSA_URL' + Write-Host " Then: pdfnative verify --input `"$PadesTPdf`" --format text" +} + +Write-Host '' +Write-Host 'Expect: the offline PAdES B-B signature always verifies; with a TSA the' +Write-Host 'B-T output additionally reports a validated RFC 3161 timestamp token.' diff --git a/samples/sign/06-timestamp.sh b/samples/sign/06-timestamp.sh new file mode 100644 index 0000000..613115a --- /dev/null +++ b/samples/sign/06-timestamp.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# sign/06-timestamp.sh — PAdES B-T: sign with an RFC 3161 trusted timestamp +# +# pdfnative-cli 1.4.0 — `sign --timestamp ` embeds a verified RFC 3161 +# timestamp token in the CMS unsigned attributes at signing time; combined +# with `--profile pades` this produces a PAdES B-T signature. +# +# Network is strictly OPT-IN. The offline part (render → PAdES B-B sign) always +# runs; the TSA request only happens when PDFNATIVE_TSA_URL is set. +# +# Prerequisites: +# - pdfnative-cli installed globally: npm install -g pdfnative-cli +# - openssl available on your PATH +# - optional: PDFNATIVE_TSA_URL (e.g. http://timestamp.digicert.com) to run +# the real timestamp step against a TSA +# +# Usage: +# bash samples/sign/06-timestamp.sh +# PDFNATIVE_TSA_URL=http://timestamp.digicert.com bash samples/sign/06-timestamp.sh +# +# Output: samples/output/sign/06-timestamp-pades-b.pdf +# samples/output/sign/06-timestamp-pades-t.pdf (network mode only) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +OUTPUT_DIR="$ROOT_DIR/samples/output" +SIGN_OUT="$OUTPUT_DIR/sign" +KEYS_DIR="$SIGN_OUT/keys" + +mkdir -p "$SIGN_OUT" "$KEYS_DIR" + +UNSIGNED_PDF="$SIGN_OUT/06-timestamp-source.pdf" +PADES_B_PDF="$SIGN_OUT/06-timestamp-pades-b.pdf" +PADES_T_PDF="$SIGN_OUT/06-timestamp-pades-t.pdf" +KEY_FILE="$KEYS_DIR/signing.key" +CERT_FILE="$KEYS_DIR/signing.crt" + +# ── Step 1: render the source document ───────────────────────────────────── +echo "→ [1/4] Rendering source document…" +pdfnative render \ + --input "$ROOT_DIR/samples/render/document/01-minimal.json" \ + --output "$UNSIGNED_PDF" +echo " ✓ Rendered: $UNSIGNED_PDF" + +# ── Step 2: generate self-signed certificate (for demo only) ─────────────── +if [ ! -f "$KEY_FILE" ] || [ ! -f "$CERT_FILE" ]; then + echo "→ [2/4] Generating self-signed certificate (demo)…" + openssl req -x509 -newkey rsa:2048 -keyout "$KEY_FILE" -out "$CERT_FILE" \ + -days 365 -nodes \ + -subj "/CN=pdfnative Demo/O=pdfnative/C=US" 2>/dev/null + echo " ✓ Key: $KEY_FILE" + echo " ✓ Cert: $CERT_FILE" +else + echo "→ [2/4] Reusing demo certificate: $CERT_FILE" +fi + +# ── Step 3: offline PAdES B-B signature (no network) ─────────────────────── +echo "→ [3/4] Signing offline with --profile pades (PAdES B-B)…" +pdfnative sign \ + --input "$UNSIGNED_PDF" \ + --output "$PADES_B_PDF" \ + --key "$KEY_FILE" \ + --cert "$CERT_FILE" \ + --profile pades \ + --reason "PAdES B-B baseline signature" +echo " ✓ Signed: $PADES_B_PDF" + +# ── Step 4: PAdES B-T — add an RFC 3161 timestamp (opt-in network) ───────── +if [ -n "${PDFNATIVE_TSA_URL:-}" ]; then + echo "→ [4/4] Signing with --timestamp against $PDFNATIVE_TSA_URL (PAdES B-T)…" + pdfnative sign \ + --input "$UNSIGNED_PDF" \ + --output "$PADES_T_PDF" \ + --key "$KEY_FILE" \ + --cert "$CERT_FILE" \ + --profile pades \ + --timestamp "$PDFNATIVE_TSA_URL" \ + --reason "PAdES B-T timestamped signature" + echo " ✓ Signed with timestamp: $PADES_T_PDF" + + echo "→ Verifying — look for the RFC 3161 timestamp (timestampPresent)…" + pdfnative verify \ + --input "$PADES_T_PDF" \ + --format text +else + echo "→ [4/4] [skipped] network step — set PDFNATIVE_TSA_URL to run against a real TSA (e.g. http://timestamp.digicert.com)" + echo " Command that would run:" + echo " pdfnative sign \\" + echo " --input \"$UNSIGNED_PDF\" \\" + echo " --output \"$PADES_T_PDF\" \\" + echo " --key \"$KEY_FILE\" \\" + echo " --cert \"$CERT_FILE\" \\" + echo " --profile pades \\" + echo " --timestamp \"\$PDFNATIVE_TSA_URL\"" + echo " Then: pdfnative verify --input \"$PADES_T_PDF\" --format text" +fi + +echo "" +echo "Expect: the offline PAdES B-B signature always verifies; with a TSA the" +echo "B-T output additionally reports a validated RFC 3161 timestamp token." diff --git a/samples/sign/08-ltv.ps1 b/samples/sign/08-ltv.ps1 new file mode 100644 index 0000000..af79a56 --- /dev/null +++ b/samples/sign/08-ltv.ps1 @@ -0,0 +1,128 @@ +# sign/08-ltv.ps1 — the full PAdES ladder: B-B → B-T → B-LT → B-LTA +# +# pdfnative-cli 1.4.0 — walks the long-term-validation ladder: +# sign --timestamp --profile pades → B-T (trusted signing time) +# ltv add --online → B-LT (OCSP/CRL into /DSS) +# doc-timestamp --url → B-LTA (RFC 3161 doc timestamp) +# ltv add --online → LTV for the doc-timestamp itself +# +# Network is strictly OPT-IN. Offline, the sample signs a PAdES B-B baseline +# and prints the ladder pedagogically; when PDFNATIVE_TSA_URL is set it really +# runs `sign --timestamp` and `doc-timestamp --url`. The `ltv add --online` +# rungs need the signer certificate to expose real OCSP/CRL endpoints (AIA / +# CDP extensions), which a throwaway demo certificate does not have — those +# rungs stay as echoed commands with an explanation. +# +# Prerequisites: +# - pdfnative-cli installed globally: npm install -g pdfnative-cli +# - openssl available on your PATH +# - optional: PDFNATIVE_TSA_URL (e.g. http://timestamp.digicert.com) +# +# Usage: +# pwsh -File samples\sign\08-ltv.ps1 +# $env:PDFNATIVE_TSA_URL='http://timestamp.digicert.com'; pwsh -File samples\sign\08-ltv.ps1 +# +# Output: samples\output\sign\08-ltv-pades-b.pdf +# samples\output\sign\08-ltv-pades-t.pdf, -pades-lta.pdf (network mode) + +$ErrorActionPreference = 'Stop' + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RootDir = Split-Path -Parent (Split-Path -Parent $ScriptDir) +$OutputDir = Join-Path $RootDir 'samples\output' +$SignOut = Join-Path $OutputDir 'sign' +$KeysDir = Join-Path $SignOut 'keys' + +New-Item -ItemType Directory -Force -Path $SignOut, $KeysDir | Out-Null + +$UnsignedPdf = Join-Path $SignOut '08-ltv-source.pdf' +$PadesBPdf = Join-Path $SignOut '08-ltv-pades-b.pdf' +$PadesTPdf = Join-Path $SignOut '08-ltv-pades-t.pdf' +$PadesLtaPdf = Join-Path $SignOut '08-ltv-pades-lta.pdf' +$KeyFile = Join-Path $KeysDir 'signing.key' +$CertFile = Join-Path $KeysDir 'signing.crt' + +# ── Step 1: render + sign a PAdES B-B baseline (always offline) ──────────── +Write-Host '→ [1/4] Rendering and signing a PAdES B-B baseline (offline)…' +& pdfnative render ` + --input (Join-Path $RootDir 'samples\render\document\02-report.json') ` + --output $UnsignedPdf +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +if (-not (Test-Path $KeyFile) -or -not (Test-Path $CertFile)) { + & openssl req -x509 -newkey rsa:2048 -keyout $KeyFile -out $CertFile ` + -days 365 -nodes ` + -subj '/CN=pdfnative Demo/O=pdfnative/C=US' 2>$null + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +} +& pdfnative sign ` + --input $UnsignedPdf ` + --output $PadesBPdf ` + --key $KeyFile ` + --cert $CertFile ` + --profile pades ` + --reason 'PAdES B-B baseline for the LTV ladder' +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +Write-Host " ✓ Signed (B-B): $PadesBPdf" + +# ── Step 2: the full ladder, pedagogically ───────────────────────────────── +Write-Host '→ [2/4] The complete PAdES ladder (each rung is an incremental revision):' +Write-Host ' B-T pdfnative sign --timestamp --profile pades' +Write-Host ' B-LT pdfnative ltv add --online # OCSP/CRL → /DSS + /VRI' +Write-Host ' B-LTA pdfnative doc-timestamp --url ' +Write-Host ' … pdfnative ltv add --online # LTV for the doc-timestamp' +Write-Host " Air-gapped variant: 'ltv collect --online' on a connected machine," +Write-Host " then 'ltv embed --data ltv.json' offline (embed never touches the network)." + +# ── Step 3: run the network rungs when a TSA is configured ───────────────── +if ($env:PDFNATIVE_TSA_URL) { + Write-Host "→ [3/4] B-T: signing with --timestamp against $($env:PDFNATIVE_TSA_URL)…" + & pdfnative sign ` + --input $UnsignedPdf ` + --output $PadesTPdf ` + --key $KeyFile ` + --cert $CertFile ` + --profile pades ` + --timestamp $env:PDFNATIVE_TSA_URL ` + --reason 'PAdES B-T for the LTV ladder' + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Write-Host " ✓ B-T: $PadesTPdf" + + Write-Host ' B-LT (ltv add --online) is not run here: the demo certificate is' + Write-Host ' self-signed and carries no OCSP/CRL endpoints (AIA/CDP), so there is' + Write-Host ' no revocation data to collect. With a CA-issued certificate you would run:' + Write-Host " pdfnative ltv add --input `"$PadesTPdf`" --online --output out-lt.pdf" + + Write-Host ' B-LTA: appending an RFC 3161 document timestamp…' + & pdfnative doc-timestamp ` + --input $PadesTPdf ` + --url $env:PDFNATIVE_TSA_URL ` + --output $PadesLtaPdf + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Write-Host " ✓ B-LTA: $PadesLtaPdf" + $InspectPdf = $PadesLtaPdf +} else { + Write-Host '→ [3/4] [skipped] network step — set PDFNATIVE_TSA_URL to run against a real TSA (e.g. http://timestamp.digicert.com)' + Write-Host ' Commands that would run:' + Write-Host " pdfnative sign --input `"$UnsignedPdf`" --output `"$PadesTPdf`" ``" + Write-Host " --key `"$KeyFile`" --cert `"$CertFile`" ``" + Write-Host ' --profile pades --timestamp $env:PDFNATIVE_TSA_URL' + Write-Host " pdfnative doc-timestamp --input `"$PadesTPdf`" --url `$env:PDFNATIVE_TSA_URL ``" + Write-Host " --output `"$PadesLtaPdf`"" + Write-Host " ('ltv add --online' additionally needs a CA-issued certificate with" + Write-Host ' real OCSP/CRL endpoints — see step 2.)' + $InspectPdf = $PadesBPdf +} + +# ── Step 4: inventory the signature fields ───────────────────────────────── +Write-Host "→ [4/4] inspect --signatures on $InspectPdf :" +& pdfnative inspect ` + --input $InspectPdf ` + --signatures ` + --format json ` + --fields signatures ` + --pretty +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +Write-Host '' +Write-Host 'Expect: the B-B baseline lists one signature field; after the network' +Write-Host 'rungs the inventory also shows a /DocTimeStamp entry (isDocTimestamp: true).' diff --git a/samples/sign/08-ltv.sh b/samples/sign/08-ltv.sh new file mode 100644 index 0000000..7c12ff6 --- /dev/null +++ b/samples/sign/08-ltv.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# sign/08-ltv.sh — the full PAdES ladder: B-B → B-T → B-LT → B-LTA +# +# pdfnative-cli 1.4.0 — walks the long-term-validation ladder: +# sign --timestamp --profile pades → B-T (trusted signing time) +# ltv add --online → B-LT (OCSP/CRL into /DSS) +# doc-timestamp --url → B-LTA (RFC 3161 doc timestamp) +# ltv add --online → LTV for the doc-timestamp itself +# +# Network is strictly OPT-IN. Offline, the sample signs a PAdES B-B baseline +# and prints the ladder pedagogically; when PDFNATIVE_TSA_URL is set it really +# runs `sign --timestamp` and `doc-timestamp --url`. The `ltv add --online` +# rungs need the signer certificate to expose real OCSP/CRL endpoints (AIA / +# CDP extensions), which a throwaway demo certificate does not have — those +# rungs stay as echoed commands with an explanation. +# +# Prerequisites: +# - pdfnative-cli installed globally: npm install -g pdfnative-cli +# - openssl available on your PATH +# - optional: PDFNATIVE_TSA_URL (e.g. http://timestamp.digicert.com) +# +# Usage: +# bash samples/sign/08-ltv.sh +# PDFNATIVE_TSA_URL=http://timestamp.digicert.com bash samples/sign/08-ltv.sh +# +# Output: samples/output/sign/08-ltv-pades-b.pdf +# samples/output/sign/08-ltv-pades-t.pdf, -pades-lta.pdf (network mode) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +OUTPUT_DIR="$ROOT_DIR/samples/output" +SIGN_OUT="$OUTPUT_DIR/sign" +KEYS_DIR="$SIGN_OUT/keys" + +mkdir -p "$SIGN_OUT" "$KEYS_DIR" + +UNSIGNED_PDF="$SIGN_OUT/08-ltv-source.pdf" +PADES_B_PDF="$SIGN_OUT/08-ltv-pades-b.pdf" +PADES_T_PDF="$SIGN_OUT/08-ltv-pades-t.pdf" +PADES_LTA_PDF="$SIGN_OUT/08-ltv-pades-lta.pdf" +KEY_FILE="$KEYS_DIR/signing.key" +CERT_FILE="$KEYS_DIR/signing.crt" + +# ── Step 1: render + sign a PAdES B-B baseline (always offline) ──────────── +echo "→ [1/4] Rendering and signing a PAdES B-B baseline (offline)…" +pdfnative render \ + --input "$ROOT_DIR/samples/render/document/02-report.json" \ + --output "$UNSIGNED_PDF" +if [ ! -f "$KEY_FILE" ] || [ ! -f "$CERT_FILE" ]; then + openssl req -x509 -newkey rsa:2048 -keyout "$KEY_FILE" -out "$CERT_FILE" \ + -days 365 -nodes \ + -subj "/CN=pdfnative Demo/O=pdfnative/C=US" 2>/dev/null +fi +pdfnative sign \ + --input "$UNSIGNED_PDF" \ + --output "$PADES_B_PDF" \ + --key "$KEY_FILE" \ + --cert "$CERT_FILE" \ + --profile pades \ + --reason "PAdES B-B baseline for the LTV ladder" +echo " ✓ Signed (B-B): $PADES_B_PDF" + +# ── Step 2: the full ladder, pedagogically ───────────────────────────────── +echo "→ [2/4] The complete PAdES ladder (each rung is an incremental revision):" +echo " B-T pdfnative sign --timestamp --profile pades" +echo " B-LT pdfnative ltv add --online # OCSP/CRL → /DSS + /VRI" +echo " B-LTA pdfnative doc-timestamp --url " +echo " … pdfnative ltv add --online # LTV for the doc-timestamp" +echo " Air-gapped variant: 'ltv collect --online' on a connected machine," +echo " then 'ltv embed --data ltv.json' offline (embed never touches the network)." + +# ── Step 3: run the network rungs when a TSA is configured ───────────────── +if [ -n "${PDFNATIVE_TSA_URL:-}" ]; then + echo "→ [3/4] B-T: signing with --timestamp against $PDFNATIVE_TSA_URL…" + pdfnative sign \ + --input "$UNSIGNED_PDF" \ + --output "$PADES_T_PDF" \ + --key "$KEY_FILE" \ + --cert "$CERT_FILE" \ + --profile pades \ + --timestamp "$PDFNATIVE_TSA_URL" \ + --reason "PAdES B-T for the LTV ladder" + echo " ✓ B-T: $PADES_T_PDF" + + echo " B-LT (ltv add --online) is not run here: the demo certificate is" + echo " self-signed and carries no OCSP/CRL endpoints (AIA/CDP), so there is" + echo " no revocation data to collect. With a CA-issued certificate you would run:" + echo " pdfnative ltv add --input \"$PADES_T_PDF\" --online --output out-lt.pdf" + + echo " B-LTA: appending an RFC 3161 document timestamp…" + pdfnative doc-timestamp \ + --input "$PADES_T_PDF" \ + --url "$PDFNATIVE_TSA_URL" \ + --output "$PADES_LTA_PDF" + echo " ✓ B-LTA: $PADES_LTA_PDF" + INSPECT_PDF="$PADES_LTA_PDF" +else + echo "→ [3/4] [skipped] network step — set PDFNATIVE_TSA_URL to run against a real TSA (e.g. http://timestamp.digicert.com)" + echo " Commands that would run:" + echo " pdfnative sign --input \"$UNSIGNED_PDF\" --output \"$PADES_T_PDF\" \\" + echo " --key \"$KEY_FILE\" --cert \"$CERT_FILE\" \\" + echo " --profile pades --timestamp \"\$PDFNATIVE_TSA_URL\"" + echo " pdfnative doc-timestamp --input \"$PADES_T_PDF\" --url \"\$PDFNATIVE_TSA_URL\" \\" + echo " --output \"$PADES_LTA_PDF\"" + echo " ('ltv add --online' additionally needs a CA-issued certificate with" + echo " real OCSP/CRL endpoints — see step 2.)" + INSPECT_PDF="$PADES_B_PDF" +fi + +# ── Step 4: inventory the signature fields ───────────────────────────────── +echo "→ [4/4] inspect --signatures on $INSPECT_PDF:" +pdfnative inspect \ + --input "$INSPECT_PDF" \ + --signatures \ + --format json \ + --fields signatures \ + --pretty + +echo "" +echo "Expect: the B-B baseline lists one signature field; after the network" +echo "rungs the inventory also shows a /DocTimeStamp entry (isDocTimestamp: true)." diff --git a/samples/sign/09-multiple-signatures.ps1 b/samples/sign/09-multiple-signatures.ps1 new file mode 100644 index 0000000..c734bb0 --- /dev/null +++ b/samples/sign/09-multiple-signatures.ps1 @@ -0,0 +1,104 @@ +# sign/09-multiple-signatures.ps1 — two signatures on one PDF (--allow-multiple) +# +# pdfnative-cli 1.4.0 — by default `sign` is idempotent and refuses to sign an +# already-signed PDF; `--allow-multiple` appends a second signature field as an +# incremental revision, keeping the first signature's bytes intact. Each +# signature gets its own form field via --field-name. 100% offline. +# +# Prerequisites: +# - pdfnative-cli installed globally: npm install -g pdfnative-cli +# - openssl available on your PATH +# +# Usage: +# pwsh -File samples\sign\09-multiple-signatures.ps1 +# +# Output: samples\output\sign\09-multi-signed-twice.pdf + +$ErrorActionPreference = 'Stop' + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RootDir = Split-Path -Parent (Split-Path -Parent $ScriptDir) +$OutputDir = Join-Path $RootDir 'samples\output' +$SignOut = Join-Path $OutputDir 'sign' +$KeysDir = Join-Path $SignOut 'keys\multi' + +New-Item -ItemType Directory -Force -Path $SignOut, $KeysDir | Out-Null + +$UnsignedPdf = Join-Path $SignOut '09-multi-source.pdf' +$OncePdf = Join-Path $SignOut '09-multi-signed-once.pdf' +$TwicePdf = Join-Path $SignOut '09-multi-signed-twice.pdf' +$Key1 = Join-Path $KeysDir 'approver1.key' +$Cert1 = Join-Path $KeysDir 'approver1.crt' +$Key2 = Join-Path $KeysDir 'approver2.key' +$Cert2 = Join-Path $KeysDir 'approver2.crt' + +# ── Step 1: render the source document ───────────────────────────────────── +Write-Host '→ [1/5] Rendering source document…' +& pdfnative render ` + --input (Join-Path $RootDir 'samples\render\document\04-invoice.json') ` + --output $UnsignedPdf +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +Write-Host " ✓ Rendered: $UnsignedPdf" + +# ── Step 2: generate two demo signer identities ──────────────────────────── +if (-not (Test-Path $Cert1) -or -not (Test-Path $Cert2)) { + Write-Host '→ [2/5] Generating two self-signed certificates (demo)…' + & openssl req -x509 -newkey rsa:2048 -keyout $Key1 -out $Cert1 ` + -days 365 -nodes ` + -subj '/CN=pdfnative Demo Approver 1/O=pdfnative/C=US' 2>$null + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + & openssl req -x509 -newkey rsa:2048 -keyout $Key2 -out $Cert2 ` + -days 365 -nodes ` + -subj '/CN=pdfnative Demo Approver 2/O=pdfnative/C=US' 2>$null + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Write-Host " ✓ Approver 1: $Cert1" + Write-Host " ✓ Approver 2: $Cert2" +} else { + Write-Host "→ [2/5] Reusing demo certificates in $KeysDir" +} + +# ── Step 3: first signature (field Approval1) ────────────────────────────── +Write-Host '→ [3/5] First signature (--field-name Approval1)…' +& pdfnative sign ` + --input $UnsignedPdf ` + --output $OncePdf ` + --key $Key1 ` + --cert $Cert1 ` + --field-name Approval1 ` + --reason 'First approval' +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +Write-Host " ✓ Signed once: $OncePdf" + +# ── Step 4: second signature (--allow-multiple, field Approval2) ─────────── +Write-Host '→ [4/5] Second signature (--allow-multiple --field-name Approval2)…' +& pdfnative sign ` + --input $OncePdf ` + --output $TwicePdf ` + --key $Key2 ` + --cert $Cert2 ` + --allow-multiple ` + --field-name Approval2 ` + --reason 'Second approval' +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +Write-Host " ✓ Signed twice: $TwicePdf" + +# ── Step 5: inventory + verify both signatures ───────────────────────────── +Write-Host '→ [5/5] inspect --signatures — expect two entries (Approval1, Approval2):' +& pdfnative inspect ` + --input $TwicePdf ` + --signatures ` + --format json ` + --fields signatures ` + --pretty +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +Write-Host '' +Write-Host '→ verify — expect both signatures to validate:' +& pdfnative verify ` + --input $TwicePdf ` + --format text +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +Write-Host '' +Write-Host 'Expect: two signature fields, and verify reports both as valid — the' +Write-Host "second revision did not break the first signature's byte range." diff --git a/samples/sign/09-multiple-signatures.sh b/samples/sign/09-multiple-signatures.sh new file mode 100644 index 0000000..4ee6fdc --- /dev/null +++ b/samples/sign/09-multiple-signatures.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# sign/09-multiple-signatures.sh — two signatures on one PDF (--allow-multiple) +# +# pdfnative-cli 1.4.0 — by default `sign` is idempotent and refuses to sign an +# already-signed PDF; `--allow-multiple` appends a second signature field as an +# incremental revision, keeping the first signature's bytes intact. Each +# signature gets its own form field via --field-name. 100% offline. +# +# Prerequisites: +# - pdfnative-cli installed globally: npm install -g pdfnative-cli +# - openssl available on your PATH +# +# Usage: +# bash samples/sign/09-multiple-signatures.sh +# +# Output: samples/output/sign/09-multi-signed-twice.pdf + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +OUTPUT_DIR="$ROOT_DIR/samples/output" +SIGN_OUT="$OUTPUT_DIR/sign" +KEYS_DIR="$SIGN_OUT/keys/multi" + +mkdir -p "$SIGN_OUT" "$KEYS_DIR" + +UNSIGNED_PDF="$SIGN_OUT/09-multi-source.pdf" +ONCE_PDF="$SIGN_OUT/09-multi-signed-once.pdf" +TWICE_PDF="$SIGN_OUT/09-multi-signed-twice.pdf" +KEY1="$KEYS_DIR/approver1.key"; CERT1="$KEYS_DIR/approver1.crt" +KEY2="$KEYS_DIR/approver2.key"; CERT2="$KEYS_DIR/approver2.crt" + +# ── Step 1: render the source document ───────────────────────────────────── +echo "→ [1/5] Rendering source document…" +pdfnative render \ + --input "$ROOT_DIR/samples/render/document/04-invoice.json" \ + --output "$UNSIGNED_PDF" +echo " ✓ Rendered: $UNSIGNED_PDF" + +# ── Step 2: generate two demo signer identities ──────────────────────────── +if [ ! -f "$CERT1" ] || [ ! -f "$CERT2" ]; then + echo "→ [2/5] Generating two self-signed certificates (demo)…" + openssl req -x509 -newkey rsa:2048 -keyout "$KEY1" -out "$CERT1" \ + -days 365 -nodes \ + -subj "/CN=pdfnative Demo Approver 1/O=pdfnative/C=US" 2>/dev/null + openssl req -x509 -newkey rsa:2048 -keyout "$KEY2" -out "$CERT2" \ + -days 365 -nodes \ + -subj "/CN=pdfnative Demo Approver 2/O=pdfnative/C=US" 2>/dev/null + echo " ✓ Approver 1: $CERT1" + echo " ✓ Approver 2: $CERT2" +else + echo "→ [2/5] Reusing demo certificates in $KEYS_DIR" +fi + +# ── Step 3: first signature (field Approval1) ────────────────────────────── +echo "→ [3/5] First signature (--field-name Approval1)…" +pdfnative sign \ + --input "$UNSIGNED_PDF" \ + --output "$ONCE_PDF" \ + --key "$KEY1" \ + --cert "$CERT1" \ + --field-name Approval1 \ + --reason "First approval" +echo " ✓ Signed once: $ONCE_PDF" + +# ── Step 4: second signature (--allow-multiple, field Approval2) ─────────── +echo "→ [4/5] Second signature (--allow-multiple --field-name Approval2)…" +pdfnative sign \ + --input "$ONCE_PDF" \ + --output "$TWICE_PDF" \ + --key "$KEY2" \ + --cert "$CERT2" \ + --allow-multiple \ + --field-name Approval2 \ + --reason "Second approval" +echo " ✓ Signed twice: $TWICE_PDF" + +# ── Step 5: inventory + verify both signatures ───────────────────────────── +echo "→ [5/5] inspect --signatures — expect two entries (Approval1, Approval2):" +pdfnative inspect \ + --input "$TWICE_PDF" \ + --signatures \ + --format json \ + --fields signatures \ + --pretty + +echo "" +echo "→ verify — expect both signatures to validate:" +pdfnative verify \ + --input "$TWICE_PDF" \ + --format text + +echo "" +echo "Expect: two signature fields, and verify reports both as valid — the" +echo "second revision did not break the first signature's byte range." diff --git a/scripts/generate-pdfa-corpus.mjs b/scripts/generate-pdfa-corpus.mjs new file mode 100644 index 0000000..1f1b7af --- /dev/null +++ b/scripts/generate-pdfa-corpus.mjs @@ -0,0 +1,347 @@ +/** + * pdfnative-cli — PDF/A validation corpus generator + * ================================================== + * Drives the BUILT CLI (`node dist/cli.cjs …`) — never a globally installed + * `pdfnative` binary — to produce a small, deterministic corpus of + * PDF/A-claiming documents under `test-output/pdfa/`, covering the + * PDF/A-relevant command surface (render + PDF/A samples, attachments, + * header/footer templates, outline, watermark, sign, metadata). It is a + * representative sample, not an exhaustive feature matrix. + * `scripts/validate-pdfa.mjs` then runs every file through the veraPDF + * reference validator. + * + * Usage: npm run build && npm run corpus:pdfa + * node scripts/generate-pdfa-corpus.mjs + * Exit: 0 when every file was written, 1 at the first CLI invocation that + * fails (its stderr is reproduced), 2 when dist/cli.cjs is missing. + * + * Dependency-free: node built-ins only, and the CLI is spawned via + * `process.execPath` (a real .exe / ELF binary — no `.bat` launcher, so no + * `shell: true` and none of the CVE-2024-27980 quoting concerns apply). + * + * Font embedding recipe: the CLI has no `embedFonts` switch — `--font latin` + * registers the bundled Noto Sans loader and `--lang latin` injects the + * matching `fontEntries`, which routes ALL Latin text away from non-embedded + * base-14 Helvetica. Positive entries therefore render with + * `--strict --font latin --lang latin` (strict mode turns any remaining + * PDF/A diagnostic into a build failure before the first output byte). + * + * Every entry carries `expectCompliant` in manifest.json. Most are `true`; the + * negative canaries (`false`) are files that claim PDF/A but are KNOWN to be + * non-conformant — the validator must see veraPDF reject them, otherwise the + * validator itself is broken ("accepts everything") and the run fails. + */ + +import { spawnSync } from 'node:child_process'; +import { createSign, generateKeyPairSync } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const OUT_DIR = join(ROOT, 'test-output', 'pdfa'); +const SPECS_DIR = join(OUT_DIR, '.specs'); +const CLI = join(ROOT, 'dist', 'cli.cjs'); +const SAMPLES = join(ROOT, 'samples', 'render'); + +if (!existsSync(CLI)) { + process.stderr.write('dist/cli.cjs not found — run `npm run build` first.\n'); + process.exit(2); +} + +// ── Self-signed RSA test certificate (node:crypto + a tiny DER encoder) ── +// Ported from pdfnative-mcp's corpus generator: a throwaway RSA-2048 key +// signs a v1 certificate with CN=Corpus Signer. Generated per run, encoded +// to PEM in memory only — never written to disk and never printed; the key +// material reaches the CLI exclusively through the child process env +// (PDFNATIVE_SIGN_KEY / PDFNATIVE_SIGN_CERT), so the parent env stays clean. + +function derLength(n) { + if (n < 0x80) return [n]; + const bytes = []; + for (let v = n; v > 0; v >>>= 8) bytes.unshift(v & 0xff); + return [0x80 | bytes.length, ...bytes]; +} +function der(tag, ...parts) { + const body = Buffer.concat(parts.map((p) => Buffer.from(p))); + return Buffer.concat([Buffer.from([tag, ...derLength(body.length)]), body]); +} +const derSeq = (...parts) => der(0x30, ...parts); +const derSet = (...parts) => der(0x31, ...parts); +function derInt(buf) { + const b = Buffer.from(buf); + return der(0x02, b[0] & 0x80 ? Buffer.concat([Buffer.from([0]), b]) : b); +} +function derOid(dotted) { + const p = dotted.split('.').map(Number); + const out = [p[0] * 40 + p[1]]; + for (const v0 of p.slice(2)) { + let v = v0; + const stack = [v & 0x7f]; + for (v >>>= 7; v > 0; v >>>= 7) stack.push((v & 0x7f) | 0x80); + out.push(...stack.reverse()); + } + return der(0x06, Buffer.from(out)); +} +const derNull = Buffer.from([0x05, 0x00]); +const derBitString = (bytes) => der(0x03, Buffer.from([0]), bytes); +function derUtcTime(date) { + const s = date.toISOString().replace(/[-:T]/g, '').slice(2, 14) + 'Z'; + return der(0x17, Buffer.from(s, 'ascii')); +} + +/** Wrap DER bytes as a PEM block (64-char base64 lines). */ +function toPem(label, derBytes) { + const b64 = derBytes.toString('base64').replace(/(.{64})/g, '$1\n').trimEnd(); + return `-----BEGIN ${label}-----\n${b64}\n-----END ${label}-----\n`; +} + +/** Build a self-signed RSA-2048 cert; returns { keyPem, certPem } (in memory only). */ +function buildRsaSelfSignedCert(cn = 'Corpus Signer') { + const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + const jwk = privateKey.export({ format: 'jwk' }); + const rsaPub = derSeq(derInt(Buffer.from(jwk.n, 'base64url')), derInt(Buffer.from(jwk.e, 'base64url'))); + const spki = derSeq(derSeq(derOid('1.2.840.113549.1.1.1'), derNull), derBitString(rsaPub)); + const sigAlg = derSeq(derOid('1.2.840.113549.1.1.11'), derNull); + const name = derSeq(derSet(derSeq(derOid('2.5.4.3'), der(0x0c, Buffer.from(cn, 'utf8'))))); + const validity = derSeq(derUtcTime(new Date(Date.now() - 60_000)), derUtcTime(new Date(Date.now() + 365 * 86_400_000))); + const tbs = derSeq(derInt(Buffer.from([1])), sigAlg, name, validity, name, spki); + const sig = createSign('sha256').update(tbs).sign(privateKey); + const certDer = derSeq(tbs, sigAlg, derBitString(sig)); + return { + keyPem: privateKey.export({ format: 'pem', type: 'pkcs8' }), + certPem: toPem('CERTIFICATE', certDer), + }; +} + +// ── CLI invocation ────────────────────────────────────────────────────── + +/** + * Run `node dist/cli.cjs `; on any failure (spawn error or non-zero + * exit) the CLI's stderr is reproduced and the generator exits 1 — a corpus + * with a missing or half-rendered file must never reach the validator. + */ +function runCli(label, args, extraEnv) { + const r = spawnSync(process.execPath, [CLI, ...args], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + maxBuffer: 64 * 1024 * 1024, + env: extraEnv ? { ...process.env, ...extraEnv } : process.env, + }); + if (r.error || r.status !== 0) { + process.stderr.write(`FAIL ${label}\n`); + if (r.error) process.stderr.write(` spawn failed: ${r.error.message ?? r.error}\n`); + const err = (r.stderr ?? '').trim(); + if (err) for (const l of err.split(/\r?\n/)) process.stderr.write(` ${l}\n`); + process.exit(1); + } +} + +// ── Corpus definition ─────────────────────────────────────────────────── + +const pdfaSample = (name) => join(SAMPLES, 'pdfa', name); +const out = (file) => join(OUT_DIR, file); + +/** + * The embedding recipe every positive entry uses (see the header comment): + * strict mode + bundled Latin font registration + fontEntries injection. + */ +const STRICT_FONTS = ['--strict', '--font', 'latin', '--lang', 'latin']; + +/** + * The exact attachment recipe from samples/run-all.js CATEGORY_FLAGS.attachments + * (absolute payload path, as run-all.js builds it). + */ +const ATTACHMENT_FLAGS = [ + '--tagged', 'pdfa3b', + '--attachment', `${join(SAMPLES, 'attachments', 'invoice.xml')}:application/xml:Source:Structured invoice payload`, +]; + +/** + * Minimal PdfParams fixture for the table-variant negative canary. The table + * variant requires the full ledger shape (docTitle / infoItems / balanceText / + * countText / footerText alongside title / headers / rows) — see + * samples/render/table-variant/01-financial-transactions.json. + */ +const TABLE_SPEC = { + docTitle: 'Corpus — table variant under PDF/A-1b (negative canary)', + title: 'Corpus table', + infoItems: [], + balanceText: '', + countText: '', + headers: ['Item', 'Qty', 'Price'], + rows: [ + { type: 'data', pointed: false, cells: ['Widget', '2', '9.99'] }, + { type: 'data', pointed: false, cells: ['Gadget', '1', '24.50'] }, + ], + footerText: 'Generated by scripts/generate-pdfa-corpus.mjs', +}; + +/** + * Corpus: `file` is the output name under test-output/pdfa/, `run` performs + * the CLI invocation(s), `command` is the human-readable manifest record. + * Later entries may consume earlier outputs (signed-/metadata- reuse the + * sample-* renders), so the order matters and execution is sequential. + */ +const CORPUS = [ + // ── Positive entries: --strict --font latin --lang latin ───────────── + { + file: 'sample-pdfa1b.pdf', + command: 'render samples/render/pdfa/01-pdfa-1b.json --strict --font latin --lang latin', + run: (f) => runCli(f, ['render', '--input', pdfaSample('01-pdfa-1b.json'), '--output', out(f), ...STRICT_FONTS]), + }, + { + file: 'sample-pdfa2b.pdf', + command: 'render samples/render/pdfa/02-pdfa-2b.json --strict --font latin --lang latin', + run: (f) => runCli(f, ['render', '--input', pdfaSample('02-pdfa-2b.json'), '--output', out(f), ...STRICT_FONTS]), + }, + { + file: 'sample-pdfa3b.pdf', + command: 'render samples/render/pdfa/03-pdfa-3b.json --strict --font latin --lang latin', + run: (f) => runCli(f, ['render', '--input', pdfaSample('03-pdfa-3b.json'), '--output', out(f), ...STRICT_FONTS]), + }, + { + file: 'sample-pdfa2u.pdf', + command: 'render samples/render/pdfa/04-pdfa-2u.json --strict --font latin --lang latin', + run: (f) => runCli(f, ['render', '--input', pdfaSample('04-pdfa-2u.json'), '--output', out(f), ...STRICT_FONTS]), + }, + { + // The exact run-all.js attachments recipe on top of the strict/fonts base: + // PDF/A-3b with an /AFRelationship Source XML payload (Factur-X pattern). + file: 'attachment-pdfa3b-xml.pdf', + command: 'render samples/render/attachments/01-pdfa3-with-xml.json --tagged pdfa3b --attachment invoice.xml:application/xml:Source:Structured invoice payload --strict --font latin --lang latin', + run: (f) => runCli(f, [ + 'render', '--input', join(SAMPLES, 'attachments', '01-pdfa3-with-xml.json'), '--output', out(f), + ...ATTACHMENT_FLAGS, ...STRICT_FONTS, + ]), + }, + { + file: 'headers-footers-pdfa2b.pdf', + command: 'render samples/render/pdfa/02-pdfa-2b.json --header-left "{title}" --footer-center "Page {page} of {pages}" --strict --font latin --lang latin', + run: (f) => runCli(f, [ + 'render', '--input', pdfaSample('02-pdfa-2b.json'), '--output', out(f), + '--header-left', '{title}', '--footer-center', 'Page {page} of {pages}', ...STRICT_FONTS, + ]), + }, + { + file: 'outline-pdfa2b.pdf', + command: 'render samples/render/pdfa/02-pdfa-2b.json --outline auto --strict --font latin --lang latin', + run: (f) => runCli(f, [ + 'render', '--input', pdfaSample('02-pdfa-2b.json'), '--output', out(f), + '--outline', 'auto', ...STRICT_FONTS, + ]), + }, + { + // Opacity 1: PDF/A-1 forbids transparency and PDF/A-2 constrains it; + // a fully opaque watermark keeps the claim safe at every level. + file: 'watermark-pdfa2b.pdf', + command: 'render samples/render/pdfa/02-pdfa-2b.json --watermark-text DRAFT --watermark-opacity 1 --strict --font latin --lang latin', + run: (f) => runCli(f, [ + 'render', '--input', pdfaSample('02-pdfa-2b.json'), '--output', out(f), + '--watermark-text', 'DRAFT', '--watermark-opacity', '1', ...STRICT_FONTS, + ]), + }, + { + // PAdES baseline-B signature over the PDF/A-2b render, via an + // incremental update (the claim must survive). Credentials travel + // through the CHILD env only (never disk, never the parent env). + file: 'signed-pdfa2b.pdf', + command: 'sign sample-pdfa2b.pdf --profile pades (throwaway self-signed RSA-2048 via env)', + run: (f) => { + const { keyPem, certPem } = buildRsaSelfSignedCert(); + runCli(f, ['sign', '--input', out('sample-pdfa2b.pdf'), '--output', out(f), '--profile', 'pades'], { + PDFNATIVE_SIGN_KEY: keyPem, + PDFNATIVE_SIGN_CERT: certPem, + }); + }, + }, + { + // Incremental /Info + XMP rewrite on a claiming file: the PDF/A-2u + // claim and metadata synchronisation (ISO 19005 6.6.2) must survive. + file: 'metadata-pdfa2u.pdf', + command: 'metadata sample-pdfa2u.pdf --title "Corpus metadata check" --author "pdfnative-cli corpus" --mod-date 2026-08-26T00:00:00Z', + run: (f) => runCli(f, [ + 'metadata', '--input', out('sample-pdfa2u.pdf'), + '--title', 'Corpus metadata check', '--author', 'pdfnative-cli corpus', + '--mod-date', '2026-08-26T00:00:00Z', '-o', out(f), + ]), + }, + + // ── Negative canaries: rendered WITHOUT --strict and WITHOUT fonts ─── + { + // The PDF/A sample as-is: base-14 Helvetica is referenced, not + // embedded, so the file claims PDF/A-2b but violates + // ISO 19005-2 §6.2.11.4.1 (all fonts used for rendering shall be + // embedded). veraPDF MUST reject it. + file: 'nofonts-pdfa2b.pdf', + expectCompliant: false, + command: 'render samples/render/pdfa/02-pdfa-2b.json (no --strict, no fonts — ISO 19005-2 6.2.11.4.1 canary)', + run: (f) => runCli(f, ['render', '--input', pdfaSample('02-pdfa-2b.json'), '--output', out(f)]), + }, + { + // Table variant under PDF/A-1b: `--variant table` (PdfParams) has no + // fontEntries channel, so the CLI CANNOT embed fonts on this path — + // Helvetica stays non-embedded and the claim violates + // ISO 19005-1 §6.3.4 (font programs shall be embedded). veraPDF MUST + // reject it. The spec fixture lives under .specs/ (not a .pdf, so the + // pruning pass never touches it) and stays out of the manifest. + file: 'table-pdfa1b-nofonts.pdf', + expectCompliant: false, + command: 'render .specs/table.json --variant table --tagged pdfa1b (no fonts possible — ISO 19005-1 6.3.4 canary)', + run: (f) => { + const spec = join(SPECS_DIR, 'table.json'); + mkdirSync(SPECS_DIR, { recursive: true }); + writeFileSync(spec, `${JSON.stringify(TABLE_SPEC, null, 2)}\n`); + runCli(f, ['render', '--input', spec, '--output', out(f), '--variant', 'table', '--tagged', 'pdfa1b']); + }, + }, +]; + +// ── Main ──────────────────────────────────────────────────────────────── + +function main() { + mkdirSync(OUT_DIR, { recursive: true }); + // Prune PDFs left over from an older corpus layout so the validator's + // "unlisted file" note only ever points at something unexpected. Only + // top-level *.pdf files are pruned — manifest.json, .specs/ and reports/ + // are never touched. + const current = new Set(CORPUS.map((e) => e.file)); + for (const stale of readdirSync(OUT_DIR).filter((f) => f.endsWith('.pdf') && !current.has(f))) { + rmSync(join(OUT_DIR, stale)); + process.stdout.write(` pruned ${stale}\n`); + } + + const manifest = []; + let totalBytes = 0; + + for (const entry of CORPUS) { + entry.run(entry.file); // exits 1 on the first failing CLI invocation + const dest = out(entry.file); + if (!existsSync(dest)) { + process.stderr.write(`FAIL ${entry.file}\n CLI exited 0 but wrote no file at ${relative(ROOT, dest)}.\n`); + return 1; + } + const bytes = statSync(dest).size; + // Sanity: written bytes must at least start like a PDF. + if (!readFileSync(dest).subarray(0, 5).equals(Buffer.from('%PDF-', 'ascii'))) { + process.stderr.write(`FAIL ${entry.file}\n output does not start with %PDF-.\n`); + return 1; + } + totalBytes += bytes; + const expectPdfAClaim = entry.expectPdfAClaim !== false; + // A file that makes no claim is never validated, so it has no compliance expectation. + const expectCompliant = expectPdfAClaim && entry.expectCompliant !== false; + manifest.push({ file: entry.file, command: entry.command, bytes, expectPdfAClaim, expectCompliant }); + const note = !expectPdfAClaim ? ', no PDF/A claim expected' : !expectCompliant ? ', NEGATIVE canary — must fail veraPDF' : ''; + process.stdout.write(` wrote ${entry.file.padEnd(32)} ${String(bytes).padStart(8)} B${note ? ` (${note.slice(2)})` : ''}\n`); + } + + const negatives = manifest.filter((m) => m.expectPdfAClaim && !m.expectCompliant).length; + writeFileSync(join(OUT_DIR, 'manifest.json'), `${JSON.stringify({ generatedBy: 'scripts/generate-pdfa-corpus.mjs', files: manifest }, null, 2)}\n`); + process.stdout.write( + `\nPDF/A corpus: ${manifest.length} file(s), ${totalBytes} bytes, ${negatives} negative canar${negatives === 1 ? 'y' : 'ies'} → test-output/pdfa/ (manifest.json written)\n`, + ); + return 0; +} + +process.exit(main()); diff --git a/scripts/validate-pdfa.mjs b/scripts/validate-pdfa.mjs new file mode 100644 index 0000000..8255c86 --- /dev/null +++ b/scripts/validate-pdfa.mjs @@ -0,0 +1,339 @@ +/** + * pdfnative-cli — veraPDF batch validation runner + * ================================================ + * Validates every PDF in `test-output/pdfa/` (the corpus written by + * scripts/generate-pdfa-corpus.mjs) against the official veraPDF reference + * validator (https://verapdf.org), using the PDF/A profile each file claims in + * its XMP metadata (`pdfaid:part` + `pdfaid:conformance` → 1b / 2b / 2u / 3b), + * and compares the outcome with the manifest's `expectCompliant` flag. + * + * Usage: + * npm run validate:pdfa # build + corpus + validate + * node scripts/validate-pdfa.mjs # validate an existing corpus only + * + * Requirements: + * - veraPDF CLI on PATH, or `VERAPDF_HOME` pointing at a veraPDF install + * (`verapdf` / `verapdf.bat` at the root or under `bin/`). + * - veraPDF is an external tool: pdfnative-cli has zero extra runtime + * dependencies and never bundles a validator. + * + * Environment: + * VERAPDF_HOME= veraPDF install directory (optional, see above). + * VERAPDF_REQUIRED=1 fail-closed: a missing veraPDF / Java, a crash, or an + * unparseable report is an INFRA failure (exit 3) + * instead of a skip. Set in CI; unset locally so a + * machine without veraPDF never blocks. + * VERAPDF_REPORT_DIR= where the raw per-file veraPDF XML reports go + * (default test-output/pdfa/reports/). CI uploads it. + * + * Outcomes per claiming file (one line each on stdout): + * PASS compliant, and the manifest expected compliance. + * FAIL non-compliant (failing rule ids listed), manifest expected compliance. + * XFAIL non-compliant as expected — a negative canary proving veraPDF rejects + * a file it must reject. + * XPASS compliant although the manifest expects a failure: the validator is + * not validating ("accepts everything") — always fatal. + * INFRA veraPDF did not produce a usable report for this file (crash, empty + * stdout, zero or several elements). Not a + * conformance verdict. + * SKIP no PDF/A claim (`expectPdfAClaim: false` outputs) — never sent to + * veraPDF. + * + * Exit codes: + * 0 — every expectation met (or veraPDF is absent and VERAPDF_REQUIRED is + * unset: install hints are printed and validation is SKIPPED — exit 0 is + * a skip, not a pass). + * 1 — a conformance expectation was not met (FAIL / XPASS), the corpus has no + * negative canary, or the coverage canary tripped (a manifest file is + * missing, or its XMP claim disagrees with the manifest). + * 2 — the corpus directory / manifest is absent (run `npm run corpus:pdfa`). + * 3 — INFRA: veraPDF unusable (only with VERAPDF_REQUIRED=1), or at least one + * file produced an INFRA outcome. + * + * Windows: a `.bat` launcher cannot be spawned without a shell (Node rejects it + * with EINVAL since the CVE-2024-27980 hardening); shell mode performs no + * escaping, so every argument is quoted explicitly. + */ + +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const CORPUS_DIR = join(ROOT, 'test-output', 'pdfa'); +const MANIFEST = join(CORPUS_DIR, 'manifest.json'); +const REPORT_DIR = process.env.VERAPDF_REPORT_DIR ? resolve(process.env.VERAPDF_REPORT_DIR) : join(CORPUS_DIR, 'reports'); +const REQUIRED = process.env.VERAPDF_REQUIRED === '1' || process.env.VERAPDF_REQUIRED === 'true'; + +const EXIT_OK = 0; +const EXIT_CONFORMANCE = 1; +const EXIT_NO_CORPUS = 2; +const EXIT_INFRA = 3; + +const log = (s) => process.stderr.write(`${s}\n`); +const out = (s) => process.stdout.write(`${s}\n`); + +// ── Locate veraPDF CLI ────────────────────────────────────────────── + +function locateVeraPdf() { + const home = process.env.VERAPDF_HOME; + if (home) { + const candidates = [join(home, 'verapdf'), join(home, 'verapdf.bat'), join(home, 'bin', 'verapdf'), join(home, 'bin', 'verapdf.bat')]; + for (const c of candidates) { + if (existsSync(c)) return c; + } + } + const probe = spawnSync(process.platform === 'win32' ? 'where' : 'which', ['verapdf'], { encoding: 'utf8' }); + if (probe.status === 0 && probe.stdout) { + return probe.stdout.trim().split(/\r?\n/)[0]; + } + return null; +} + +/** Run `verapdf --version`; returns the version line or an error string (never throws). */ +function probeVeraPdf(verapdf) { + const r = runVeraPdf(verapdf, ['--version']); + if (r.error) return { ok: false, detail: r.error }; + const line = (r.stdout || '').split(/\r?\n/).find((l) => /verapdf/i.test(l)); + if (r.status !== 0 || !line) { + return { ok: false, detail: `exit ${r.status}; stderr: ${(r.stderr || '').trim().slice(0, 400) || '(empty)'}` }; + } + return { ok: true, detail: line.trim() }; +} + +function runVeraPdf(verapdf, args) { + const isBatch = /\.(bat|cmd)$/i.test(verapdf); + const quote = (s) => (isBatch ? `"${s}"` : s); + const r = spawnSync(quote(verapdf), args.map(quote), { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + shell: isBatch, + maxBuffer: 64 * 1024 * 1024, + }); + return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '', error: r.error ? String(r.error.message ?? r.error) : null }; +} + +// ── PDF/A claim detection (XMP) ───────────────────────────────────── + +/** Returns `{ part, conformance, profile }` or null when the file does not claim PDF/A. */ +function detectPdfAClaim(file) { + const txt = readFileSync(file).toString('latin1'); + const part = txt.match(/(\d)<\/pdfaid:part>/)?.[1]; + const conf = txt.match(/([A-Z])<\/pdfaid:conformance>/)?.[1]; + if (!part || !conf) return null; + return { part: Number.parseInt(part, 10), conformance: conf, profile: `${part}${conf.toLowerCase()}` }; +} + +// ── veraPDF invocation + report parsing ───────────────────────────── + +/** + * Parse ONE veraPDF XML report. We run veraPDF with exactly one input file, so + * the report must contain exactly one element; anything + * else (none, several) is an INFRA outcome rather than a verdict. The + * `isCompliant` attribute is read from that element only — never matched + * globally across the document. + */ +function parseReport(xml) { + const reports = Array.from(xml.matchAll(/]*)>/g)); + if (reports.length !== 1) { + return { kind: 'infra', detail: `expected exactly one , found ${reports.length}` }; + } + const attrs = reports[0][1]; + const compliant = /\bisCompliant="(true|false)"/.exec(attrs)?.[1]; + if (compliant === undefined) { + return { kind: 'infra', detail: ' has no isCompliant attribute' }; + } + const flavour = /\bprofileName="([^"]*)"/.exec(attrs)?.[1] ?? ''; + const failedRules = Array.from( + xml.matchAll(/]*\bspecification="[^"]*"[^>]*\bclause="([^"]+)"[^>]*\btestNumber="([^"]+)"[^>]*\bstatus="failed"/gi), + ).map((m) => `${m[1]} t${m[2]}`); + return { kind: 'verdict', compliant: compliant === 'true', flavour, failedRules: Array.from(new Set(failedRules)) }; +} + +function validateFile(verapdf, file, profile, reportBase) { + // veraPDF prints XML to stdout. Its exit code is NOT the verdict (a + // non-compliant file may still exit 0 or 1 depending on the version), so + // the XML is always parsed; a missing / unparseable report is INFRA. + const r = runVeraPdf(verapdf, ['--format', 'xml', '--flavour', profile, file]); + writeFileSync(`${reportBase}.xml`, r.stdout); + if (r.stderr.trim().length > 0) writeFileSync(`${reportBase}.stderr.txt`, r.stderr); + if (r.error) return { kind: 'infra', detail: `spawn failed: ${r.error}`, stderr: r.stderr }; + if (r.stdout.trim().length === 0) { + return { kind: 'infra', detail: `empty stdout (exit ${r.status})`, stderr: r.stderr }; + } + return { ...parseReport(r.stdout), stderr: r.stderr, exit: r.status }; +} + +// ── Main ───────────────────────────────────────────────────────────── + +function printMissingVeraPdfHelp() { + const lines = [ + 'veraPDF CLI not found.', + '', + ' pdfnative-cli does not bundle a validator (zero-dependency policy).', + ' Install veraPDF locally to validate the PDF/A corpus, or use the', + ' online demo at https://demo.verapdf.org for a one-off check.', + '', + ' Install hints:', + ' macOS : brew install --cask verapdf', + ' Linux : https://docs.verapdf.org/install/ → download zip → java -jar installer (headless install)', + ' Windows : https://docs.verapdf.org/install/ (GUI installer, ships verapdf.bat) or Chocolatey/Scoop', + '', + ' After install, expose it via PATH or set VERAPDF_HOME to the', + ' install directory (the one containing `verapdf` or `verapdf.bat`).', + '', + ' See CONTRIBUTING.md.', + ]; + for (const l of lines) log(l); +} + +function infraExit(reason) { + out(` INFRA ${reason}`); + out('\nveraPDF infrastructure failure (VERAPDF_REQUIRED=1): nothing was validated.'); + return EXIT_INFRA; +} + +function main() { + if (!existsSync(CORPUS_DIR) || !existsSync(MANIFEST)) { + log('No PDF/A corpus found in test-output/pdfa/. Run `npm run corpus:pdfa` first.'); + return EXIT_NO_CORPUS; + } + + const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8')); + const entries = Array.isArray(manifest.files) ? manifest.files : []; + const listed = entries.map((f) => f.file); + + // Coverage canary: every manifest entry must exist on disk, and its XMP + // claim must match the manifest's expectation. Generated documents must + // claim PDF/A; entries flagged `expectPdfAClaim: false` (outputs of + // page-tree operations that rebuild the document without the source XMP) + // must NOT. Either drifting means the corpus generator or the engine + // changed behaviour — fail loudly, never shrink silently. + const claimed = []; + const skipped = []; + let canaryFailures = 0; + for (const entry of entries) { + const name = entry.file; + const file = join(CORPUS_DIR, name); + if (!existsSync(file)) { + log(`Coverage canary: ${name} is listed in manifest.json but missing on disk.`); + canaryFailures++; + continue; + } + const claim = detectPdfAClaim(file); + const expectClaim = entry.expectPdfAClaim !== false; + if (expectClaim && claim === null) { + log(`Coverage canary: ${name} does not claim PDF/A in its XMP metadata.`); + canaryFailures++; + continue; + } + if (!expectClaim && claim !== null) { + log(`Coverage canary: ${name} now claims PDF/A-${claim.profile} but the manifest expects no claim.`); + canaryFailures++; + continue; + } + if (claim === null) skipped.push(file); + else claimed.push({ file, name, claim, expectCompliant: entry.expectCompliant !== false }); + } + const unlisted = readdirSync(CORPUS_DIR).filter((f) => f.endsWith('.pdf') && !listed.includes(f)); + if (unlisted.length > 0) { + log(`Note: ${unlisted.length} PDF(s) in test-output/pdfa/ are not in manifest.json and are ignored: ${unlisted.join(', ')}`); + } + if (listed.length === 0) { + log('manifest.json lists no files. Run `npm run corpus:pdfa` first.'); + return EXIT_CONFORMANCE; + } + if (canaryFailures > 0) { + log(`\nCoverage canary failed for ${canaryFailures} of ${listed.length} file(s).`); + return EXIT_CONFORMANCE; + } + const negatives = claimed.filter((c) => !c.expectCompliant); + log( + `Corpus: ${listed.length} file(s) in manifest.json — ${claimed.length} claim PDF/A (${negatives.length} negative canar${negatives.length === 1 ? 'y' : 'ies'}), ${skipped.length} file(s) without a claim (as expected).`, + ); + if (negatives.length === 0) { + // Without a file veraPDF must reject, a validator that accepts + // everything would be indistinguishable from a fully compliant corpus. + log('Negative canary missing: manifest.json has no claiming file with expectCompliant: false. Regenerate the corpus.'); + return EXIT_CONFORMANCE; + } + + const verapdf = locateVeraPdf(); + if (!verapdf) { + printMissingVeraPdfHelp(); + if (REQUIRED) return infraExit('veraPDF CLI not found (PATH / VERAPDF_HOME)'); + out('\nSKIPPED: veraPDF not installed — nothing was validated (exit 0 is a skip, not a pass; set VERAPDF_REQUIRED=1 to fail instead).'); + return EXIT_OK; + } + const probe = probeVeraPdf(verapdf); + if (!probe.ok) { + log(`veraPDF at ${verapdf} could not be executed (is Java installed?): ${probe.detail}`); + if (REQUIRED) return infraExit(`veraPDF launcher unusable: ${probe.detail}`); + out('\nSKIPPED: veraPDF launcher unusable — nothing was validated (exit 0 is a skip, not a pass; set VERAPDF_REQUIRED=1 to fail instead).'); + return EXIT_OK; + } + mkdirSync(REPORT_DIR, { recursive: true }); + log(`Using ${probe.detail} (${verapdf})${REQUIRED ? ' — VERAPDF_REQUIRED=1 (fail-closed)' : ''}`); + log(`Raw reports → ${relative(ROOT, REPORT_DIR).split('\\').join('/')}/`); + log(`Validating ${claimed.length} file(s)…`); + + const counts = { PASS: 0, FAIL: 0, XFAIL: 0, XPASS: 0, INFRA: 0 }; + const showRules = (rules) => { + const shown = rules.slice(0, 5); + for (const rule of shown) out(` - ${rule}`); + if (rules.length > shown.length) out(` … (${rules.length - shown.length} more)`); + if (rules.length === 0) out(' - (no failed elements in the report)'); + }; + for (const { file, name, claim, expectCompliant } of claimed) { + const rel = relative(ROOT, file).split('\\').join('/'); + const result = validateFile(verapdf, file, claim.profile, join(REPORT_DIR, name.replace(/\.pdf$/i, ''))); + if (result.kind === 'infra') { + counts.INFRA++; + out(` INFRA [${claim.profile}] ${rel} (${result.detail})`); + if (result.stderr.trim()) { + for (const l of result.stderr.trim().split(/\r?\n/).slice(0, 6)) out(` ! ${l}`); + } + continue; + } + if (result.stderr.trim()) { + // veraPDF warnings (e.g. font parsing notes) are informational but must not vanish. + for (const l of result.stderr.trim().split(/\r?\n/).slice(0, 3)) log(` note ${rel}: ${l}`); + } + if (result.compliant && expectCompliant) { + counts.PASS++; + out(` PASS [${claim.profile}] ${rel}`); + } else if (!result.compliant && !expectCompliant) { + counts.XFAIL++; + out(` XFAIL [${claim.profile}] ${rel} (negative canary rejected as expected)`); + showRules(result.failedRules); + } else if (!result.compliant) { + counts.FAIL++; + out(` FAIL [${claim.profile}] ${rel}`); + showRules(result.failedRules); + } else { + counts.XPASS++; + out(` XPASS [${claim.profile}] ${rel} (negative canary ACCEPTED — the validator is not validating)`); + } + } + + for (const file of skipped) { + out(` SKIP [none] ${relative(ROOT, file).split('\\').join('/')} (no PDF/A claim)`); + } + + out(''); + out(`Summary: ${counts.PASS} PASS, ${counts.XFAIL} XFAIL, ${counts.FAIL} FAIL, ${counts.XPASS} XPASS, ${counts.INFRA} INFRA, ${skipped.length} SKIP (of ${claimed.length} validated).`); + if (counts.INFRA > 0) { + out('INFRA: veraPDF produced no usable report for some files — not a conformance verdict. See the raw reports.'); + return EXIT_INFRA; + } + if (counts.XPASS > 0) { + out('XPASS: a file that must be rejected was accepted — the validator accepts everything; do not trust the PASS lines.'); + return EXIT_CONFORMANCE; + } + if (counts.FAIL > 0) return EXIT_CONFORMANCE; + out('All expectations met.'); + return EXIT_OK; +} + +process.exit(main()); diff --git a/src/commands/annotate.ts b/src/commands/annotate.ts index 324780b..eab8a38 100644 --- a/src/commands/annotate.ts +++ b/src/commands/annotate.ts @@ -3,6 +3,10 @@ // described by a JSON array (`--annotations `), each entry being a markup // annotation plus a 1-based `page`. The document is updated with an incremental // save, so the original bytes are preserved (existing signatures stay intact). +// Encrypted documents are supported via --password / $PDFNATIVE_PASSWORD: the +// reader decrypts transparently and the modifier re-encrypts the appended +// annotation objects under the source's existing scheme (createModifier itself +// takes no password — the { password } option rides on openPdf). import { openPdf, @@ -14,6 +18,7 @@ import { type ParsedArgs, getStringFlag, hasFlag } from '../utils/args.js'; import { readFileOrStdin, readBinaryFile, writeOutput, assertJsonSizeLimit } from '../utils/io.js'; import { CliError, ErrorCode } from '../utils/error.js'; import { emitStatus, isDryRun } from '../utils/agent.js'; +import { resolveSourcePassword, mapPdfError } from '../utils/pdfops.js'; const ANNOTATION_TYPES = new Set([ 'text', 'highlight', 'underline', 'strikeout', 'squiggly', @@ -126,6 +131,7 @@ export async function annotate(args: ParsedArgs): Promise { const inputPath = getStringFlag(args.flags, 'input', 'i'); const outputPath = getStringFlag(args.flags, 'output', 'o'); const annotationsPath = getStringFlag(args.flags, 'annotations'); + const password = resolveSourcePassword(args.flags); const dryRun = hasFlag(args.flags, 'dry-run') || isDryRun(); if (annotationsPath === undefined) { @@ -141,10 +147,11 @@ export async function annotate(args: ParsedArgs): Promise { const reader = (() => { try { - return openPdf(pdfBytes); + return openPdf(pdfBytes, password !== undefined ? { password } : undefined); } catch (e) { - const message = e instanceof Error ? e.message : String(e); - throw new CliError(`Failed to read PDF: ${message}`, 1, ErrorCode.PARSE); + // Wrong/missing password → E_PASSWORD; unsupported scheme → + // E_UNSUPPORTED; anything else → E_PARSE. Never echoes the password. + throw mapPdfError(e, 'Failed to read PDF'); } })(); diff --git a/src/commands/batch.ts b/src/commands/batch.ts index 7775ea3..c59ef04 100644 --- a/src/commands/batch.ts +++ b/src/commands/batch.ts @@ -1,18 +1,29 @@ -// `pdfnative batch` — render every JSON file in a directory to PDF. +// `pdfnative batch` — batch orchestration. // -// Reuses the full `render` pipeline per file (so every render flag — variant, -// layout, smart tables, PDF/A, compression … — is honoured) and runs files -// through a bounded-concurrency worker pool. Reports a per-file summary and -// exits non-zero when any render fails. +// Two mutually exclusive modes: +// • Directory mode (--input-dir/--output-dir): render every JSON file in a +// directory through the full `render` pipeline (every render flag — +// variant, layout, smart tables, PDF/A, compression … — is honoured) with +// a bounded-concurrency worker pool. +// • Manifest mode (--manifest tasks.json): run an ordered multi-command +// pipeline (render → sign → encrypt → …) with "@id" output references, +// strict pre-validation and an offline-by-default network policy. See +// src/utils/manifest.ts. -import { readdir, mkdir } from 'node:fs/promises'; -import { join, basename, extname } from 'node:path'; +import { readdir, mkdir, readFile } from 'node:fs/promises'; +import { join, basename, dirname, extname, resolve } from 'node:path'; import { type ParsedArgs, getStringFlag, hasFlag } from '../utils/args.js'; -import { validatePath } from '../utils/io.js'; -import { CliError, ErrorCode } from '../utils/error.js'; +import { validatePath, assertJsonSizeLimit } from '../utils/io.js'; +import { CliError, ErrorCode, type ErrorCodeValue } from '../utils/error.js'; import { isJsonMode, isDryRun } from '../utils/agent.js'; import { selectFields, serializeJson, parseFieldList } from '../utils/projection.js'; import { style } from '../utils/colors.js'; +import { + parseManifest, + assertOfflinePolicy, + type ManifestPlan, + type ManifestTaskPlan, +} from '../utils/manifest.js'; import { render } from './render.js'; // Flags consumed by `batch` itself and therefore NOT forwarded to `render`. @@ -20,6 +31,7 @@ const BATCH_ONLY_FLAGS = new Set([ 'input-dir', 'output-dir', 'concurrency', 'fail-fast', 'format', 'input', 'i', 'output', 'o', 'watch', 'stream', 'stream-page-by-page', 'summary', 'fields', 'pretty', + 'manifest', 'allow-network', 'continue-on-error', ]); interface FileResult { @@ -71,9 +83,230 @@ async function runPool( await Promise.all(runners); } +type CommandFn = (args: ParsedArgs) => Promise; + +/** + * Dynamically import a manifest task's command function — mirroring + * `loadCommand()` in src/index.ts, but local so `batch` never imports the + * dispatcher. Commands from parallel v1.4.0 tranches that are not present in + * this build fall through to a computed import and fail with E_UNSUPPORTED. + */ +async function loadTaskCommand(name: string): Promise { + switch (name) { + case 'render': return (await import('./render.js')).render; + case 'sign': return (await import('./sign.js')).sign; + case 'verify': return (await import('./verify.js')).verify; + case 'inspect': return (await import('./inspect.js')).inspect; + case 'merge': return (await import('./merge.js')).merge; + case 'split': return (await import('./split.js')).split; + case 'extract': return (await import('./extract.js')).extract; + case 'extract-text': return (await import('./extract-text.js')).extractTextCmd; + case 'fill': return (await import('./fill.js')).fill; + case 'encrypt': return (await import('./encrypt.js')).encrypt; + case 'decrypt': return (await import('./decrypt.js')).decrypt; + case 'annotate': return (await import('./annotate.js')).annotate; + case 'ltv': return (await import('./ltv.js')).ltv; + case 'doc-timestamp': return (await import('./docTimestamp.js')).docTimestamp; + case 'metadata': return (await import('./metadata.js')).metadata; + case 'compare': return (await import('./compare.js')).compare; + default: { + // Safety net for whitelisted commands whose module is missing from + // this build (e.g. a parallel-tranche module not merged yet). A + // computed specifier keeps this file free of static references to + // files that may not exist; add a literal case above when a module + // lands so the bundler inlines it into dist/cli.cjs. + const specifier = `./${name}.js`; + let mod: Record; + try { + mod = (await import(specifier)) as Record; + } catch { + throw new CliError( + `Manifest command "${name}" is not available in this build.`, + 1, + ErrorCode.UNSUPPORTED, + ); + } + const camel = name.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); + const fn = mod[camel] ?? mod[`${camel}Cmd`]; + if (typeof fn !== 'function') { + throw new CliError( + `Manifest command "${name}" is not available in this build.`, + 1, + ErrorCode.UNSUPPORTED, + ); + } + return fn as CommandFn; + } + } +} + +interface ManifestTaskResult { + readonly id: string; + readonly command: string; + readonly ok: boolean; + readonly output?: string; + readonly error?: { readonly code: ErrorCodeValue; readonly message: string }; + readonly skipped?: true; +} + +/** Write the final manifest summary (stdout) honouring the projection flags. */ +function emitManifestSummary( + args: ParsedArgs, + format: 'json' | 'text', + counts: { total: number; succeeded: number; failed: number; skipped: number }, + tasks: readonly ManifestTaskResult[], + dryRun: boolean, +): void { + if (format === 'json') { + const base: Record = { + ok: counts.failed === 0, + command: 'batch', + mode: 'manifest', + ...(dryRun ? { dryRun: true } : {}), + total: counts.total, + succeeded: counts.succeeded, + failed: counts.failed, + skipped: counts.skipped, + }; + let out: unknown = hasFlag(args.flags, 'summary') ? base : { ...base, tasks }; + const fieldsRaw = getStringFlag(args.flags, 'fields'); + if (fieldsRaw !== undefined) { + out = selectFields(out, parseFieldList(fieldsRaw)); + } + const pretty = hasFlag(args.flags, 'pretty') || !isJsonMode(); + process.stdout.write(serializeJson(out, pretty) + '\n'); + } else if (dryRun) { + process.stdout.write( + `Dry run: ${counts.total} task(s) validated, nothing executed.\n`, + ); + } else { + process.stdout.write( + `Manifest: ${counts.succeeded}/${counts.total} task(s) succeeded, ` + + `${counts.failed} failed, ${counts.skipped} skipped.\n`, + ); + } +} + +/** Execute (or dry-run) a validated manifest plan sequentially. */ +async function runManifest(manifestPath: string, args: ParsedArgs): Promise { + const format = isJsonMode() ? 'json' : (getStringFlag(args.flags, 'format') ?? 'text'); + if (format !== 'json' && format !== 'text') { + throw new CliError(`Invalid --format value "${format}". Valid: json, text.`, 2); + } + const allowNetwork = hasFlag(args.flags, 'allow-network'); + const continueOnError = hasFlag(args.flags, 'continue-on-error'); + const dryRun = hasFlag(args.flags, 'dry-run') || isDryRun(); + + validatePath(manifestPath); + let rawBuf: Buffer; + try { + rawBuf = await readFile(manifestPath); + } catch { + throw new CliError(`Cannot read --manifest: ${manifestPath}`, 1, ErrorCode.IO); + } + assertJsonSizeLimit(rawBuf); + const raw = rawBuf.toString('utf8'); + + const plan: ManifestPlan = parseManifest(raw, dirname(resolve(manifestPath))); + assertOfflinePolicy(plan, allowNetwork); + const total = plan.tasks.length; + + if (dryRun) { + // Everything is validated (structure, whitelist, @ref graph, network + // policy). Print the plan and stop — nothing is created or executed. + if (format === 'text') { + plan.tasks.forEach((task: ManifestTaskPlan, i: number) => { + const target = task.output !== undefined ? ` → ${task.output}` : ''; + process.stdout.write(`plan [${i + 1}/${total}] ${task.command} ${task.id}${target}\n`); + }); + } + const planned = plan.tasks.map((t): ManifestTaskResult => ({ + id: t.id, + command: t.command, + ok: true, + ...(t.output !== undefined ? { output: t.output } : {}), + })); + emitManifestSummary(args, format, { total, succeeded: 0, failed: 0, skipped: 0 }, planned, true); + return; + } + + const results: ManifestTaskResult[] = []; + const status = new Map(); + let aborted = false; + let firstErrorCode: ErrorCodeValue | undefined; + + for (const [i, task] of plan.tasks.entries()) { + const label = `→ [${i + 1}/${total}] ${task.command} ${task.id}`; + const brokenDep = task.dependsOn.find((dep) => status.get(dep) !== 'ok'); + if (aborted || brokenDep !== undefined) { + status.set(task.id, 'skipped'); + results.push({ + id: task.id, + command: task.command, + ok: false, + skipped: true, + ...(task.output !== undefined ? { output: task.output } : {}), + }); + progress(`${label} … ${style('skipped', 'yellow')}`); + continue; + } + try { + if (task.outputDir !== undefined) { + await mkdir(task.outputDir, { recursive: true }); + } + const fn = await loadTaskCommand(task.command); + await fn({ flags: { ...task.flags }, positionals: [] }); + status.set(task.id, 'ok'); + results.push({ + id: task.id, + command: task.command, + ok: true, + ...(task.output !== undefined ? { output: task.output } : {}), + }); + progress(`${label} … ${style('ok', 'green')}`); + } catch (e) { + const code: ErrorCodeValue = e instanceof CliError ? e.code : ErrorCode.RUNTIME; + const message = e instanceof Error ? e.message : String(e); + firstErrorCode ??= code; + status.set(task.id, 'failed'); + results.push({ + id: task.id, + command: task.command, + ok: false, + error: { code, message }, + }); + progress(`${label} … ${style('failed', 'red')} (${message})`); + if (!continueOnError) aborted = true; + } + } + + const failed = results.filter((r) => r.error !== undefined).length; + const skipped = results.filter((r) => r.skipped === true).length; + const succeeded = total - failed - skipped; + + emitManifestSummary(args, format, { total, succeeded, failed, skipped }, results, false); + + if (failed > 0) { + throw new CliError('', 1, firstErrorCode); + } +} + export async function batch(args: ParsedArgs): Promise { const inputDir = getStringFlag(args.flags, 'input-dir'); const outputDir = getStringFlag(args.flags, 'output-dir'); + + // Manifest mode — mutually exclusive with the directory-render mode. + const manifestPath = getStringFlag(args.flags, 'manifest'); + if (manifestPath !== undefined) { + if (inputDir !== undefined || outputDir !== undefined) { + throw new CliError( + '--manifest is mutually exclusive with --input-dir/--output-dir.', + 2, + ); + } + await runManifest(manifestPath, args); + return; + } // Agent mode (global --json) forces a machine-readable summary on stdout. const format = isJsonMode() ? 'json' : (getStringFlag(args.flags, 'format') ?? 'text'); const failFast = hasFlag(args.flags, 'fail-fast'); diff --git a/src/commands/compare.ts b/src/commands/compare.ts new file mode 100644 index 0000000..955fb2a --- /dev/null +++ b/src/commands/compare.ts @@ -0,0 +1,462 @@ +// `pdfnative compare` — textual + structural diff of two PDF documents. +// +// Two comparison modes, combinable (`--mode text|structure|both`, default both): +// • structure — page count, per-page geometry (MediaBox size, Trim/Bleed/Art +// boxes, /UserUnit — all within ±--tolerance points), /Info metadata +// (Title/Author/Subject/Keywords/Trapped), form-field inventory, per-page +// annotation counts, encryption scheme, and signature inventory. +// • text — per-page reading-order text (pdfnative `extractText`), compared +// line by line (or as whitespace-collapsed text with --ignore-whitespace), +// optionally restricted with --pages. +// +// A VISUAL (rasterised, pixel-level) diff is intentionally OUT OF SCOPE: +// pdfnative has no rasteriser. This command compares content and structure, +// never rendered pixels. +// +// Exit codes: 0 identical; differences → the report is printed to stdout FIRST +// (both formats), then exit 1 with E_CHECK_FAILED (same convention as +// `inspect --check`); unreadable file → E_IO (1); bad usage → 2. + +import { openPdf, extractText, listSignatures, readFormFields } from '../core-bridge/index.js'; +import type { PdfReader, PdfDict, PdfValue, ExtractTextOptions } from '../core-bridge/index.js'; +import { type ParsedArgs, getStringFlag, hasFlag } from '../utils/args.js'; +import { readBinaryFile } from '../utils/io.js'; +import { CliError, ErrorCode } from '../utils/error.js'; +import { emitStatus, isJsonMode } from '../utils/agent.js'; +import { serializeJson } from '../utils/projection.js'; +import { mapPdfError } from '../utils/pdfops.js'; +import { parsePageList } from '../utils/pages.js'; + +type DiffKind = + | 'pageCount' | 'pageSize' | 'box' | 'userUnit' | 'metadata' + | 'formFields' | 'annotations' | 'encryption' | 'signatures' | 'text'; + +/** One reported difference. `a`/`b` are always JSON-serializable — never raw bytes. */ +interface Difference { + readonly kind: DiffKind; + readonly page?: number; // 1-based + readonly path?: string; + readonly a?: unknown; + readonly b?: unknown; + readonly detail?: string; +} + +type CompareMode = 'structure' | 'text'; + +interface LoadedDoc { + readonly label: 'A' | 'B'; + readonly path: string; + readonly bytes: Uint8Array; + readonly reader: PdfReader; + readonly password: string | undefined; +} + +const VALID_MODES = new Set(['text', 'structure', 'both']); +const VALID_FORMATS = new Set(['text', 'json']); +/** Optional page boxes compared coordinate-wise when present (ISO 32000-1 §14.11.2). */ +const PAGE_BOXES = ['TrimBox', 'BleedBox', 'ArtBox'] as const; +/** /Info keys compared in structure mode. */ +const INFO_KEYS = ['Title', 'Author', 'Subject', 'Keywords', 'Trapped'] as const; +const EXCERPT_MAX = 160; + +function parseTolerance(raw: string | undefined): number { + if (raw === undefined) return 0; + const n = Number.parseFloat(raw.trim()); + if (!Number.isFinite(n) || n < 0) { + throw new CliError(`Invalid --tolerance "${raw}". Expected a number >= 0 (points).`, 2); + } + return n; +} + +async function loadDoc(label: 'A' | 'B', path: string, password: string | undefined): Promise { + let bytes: Uint8Array; + try { + bytes = await readBinaryFile(path); + } catch (e) { + if (e instanceof CliError) throw e; + const message = e instanceof Error ? e.message : String(e); + throw new CliError(`Cannot read PDF ${label} ("${path}"): ${message}`, 1, ErrorCode.IO); + } + let reader: PdfReader; + try { + reader = openPdf(bytes, password !== undefined ? { password } : undefined); + } catch (e) { + throw mapPdfError(e, `Failed to open PDF ${label} ("${path}")`); + } + return { label, path, bytes, reader, password }; +} + +// ── Structure helpers ───────────────────────────────────────────────── + +/** Read a page-dictionary array of `len` numbers (resolving refs), or null. */ +function readNumberArray(reader: PdfReader, dict: PdfDict, key: string, len: number): readonly number[] | null { + const raw = dict.get(key); + if (raw === undefined) return null; + const val = reader.resolveValue(raw); + if (!Array.isArray(val) || val.length !== len) return null; + const out: number[] = []; + for (const el of val) { + const n = reader.resolveValue(el as PdfValue); + if (typeof n !== 'number' || !Number.isFinite(n)) return null; + out.push(n); + } + return out; +} + +function boxSize(box: readonly number[]): { readonly width: number; readonly height: number } { + return { + width: Math.abs((box[2] as number) - (box[0] as number)), + height: Math.abs((box[3] as number) - (box[1] as number)), + }; +} + +function readUserUnit(reader: PdfReader, page: PdfDict): number { + const raw = page.get('UserUnit'); + if (raw === undefined) return 1; + const v = reader.resolveValue(raw); + return typeof v === 'number' && Number.isFinite(v) ? v : 1; +} + +/** /Info string value (parens-wrapped literals unwrapped), or null. */ +function infoValue(reader: PdfReader, info: PdfDict | null, key: string): string | null { + if (info === null) return null; + const raw = info.get(key); + if (raw === undefined) return null; + const val = reader.resolveValue(raw); + if (typeof val !== 'string') return null; + const trimmed = val.trim(); + if (trimmed.startsWith('(') && trimmed.endsWith(')')) return trimmed.slice(1, -1); + return trimmed; +} + +interface FormSummary { readonly count: number; readonly names: readonly string[] } + +/** Form-field inventory; a document without an AcroForm yields { 0, [] }. */ +function formSummary(doc: LoadedDoc): FormSummary { + try { + const fields = readFormFields(doc.bytes, doc.password !== undefined ? { password: doc.password } : undefined); + return { count: fields.length, names: fields.map((f) => f.name).sort() }; + } catch { + return { count: 0, names: [] }; + } +} + +interface SignatureSummary { + readonly count: number; + readonly fieldNames: readonly (string | null)[]; + readonly docTimestamps: number; +} + +function signatureSummary(doc: LoadedDoc): SignatureSummary { + try { + const sigs = listSignatures(doc.bytes); + return { + count: sigs.length, + fieldNames: sigs.map((s) => s.fieldName ?? null), + docTimestamps: sigs.filter((s) => s.isDocTimestamp).length, + }; + } catch { + return { count: 0, fieldNames: [], docTimestamps: 0 }; + } +} + +type EncryptionSummary = { readonly algorithm: string; readonly revision: number } | null; + +function encryptionSummary(doc: LoadedDoc): EncryptionSummary { + const enc = doc.reader.encryption; + return enc === null ? null : { algorithm: enc.algorithm, revision: enc.revision }; +} + +function annotationCount(reader: PdfReader, pageIndex: number): number { + try { + return reader.getAnnotations(pageIndex).length; + } catch { + return 0; + } +} + +function sameJson(a: unknown, b: unknown): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +function fmtPt(n: number): number { + return Number(n.toFixed(2)); +} + +function compareStructure(a: LoadedDoc, b: LoadedDoc, tolerance: number, minPages: number): Difference[] { + const diffs: Difference[] = []; + const within = (x: number, y: number): boolean => Math.abs(x - y) <= tolerance; + + // Per-page geometry over the pages both documents share. + for (let i = 0; i < minPages; i++) { + const pageA = a.reader.getPage(i); + const pageB = b.reader.getPage(i); + + const mediaA = readNumberArray(a.reader, pageA, 'MediaBox', 4); + const mediaB = readNumberArray(b.reader, pageB, 'MediaBox', 4); + if (mediaA === null || mediaB === null) { + if ((mediaA === null) !== (mediaB === null)) { + diffs.push({ + kind: 'pageSize', page: i + 1, a: mediaA, b: mediaB, + detail: 'MediaBox present in one document only', + }); + } + } else { + const sizeA = boxSize(mediaA); + const sizeB = boxSize(mediaB); + if (!within(sizeA.width, sizeB.width) || !within(sizeA.height, sizeB.height)) { + diffs.push({ + kind: 'pageSize', page: i + 1, + a: { width: sizeA.width, height: sizeA.height }, + b: { width: sizeB.width, height: sizeB.height }, + detail: `${fmtPt(sizeA.width)}x${fmtPt(sizeA.height)}pt vs ${fmtPt(sizeB.width)}x${fmtPt(sizeB.height)}pt`, + }); + } + } + + for (const boxName of PAGE_BOXES) { + const boxA = readNumberArray(a.reader, pageA, boxName, 4); + const boxB = readNumberArray(b.reader, pageB, boxName, 4); + if (boxA === null && boxB === null) continue; + if (boxA === null || boxB === null) { + diffs.push({ + kind: 'box', page: i + 1, path: boxName, a: boxA, b: boxB, + detail: `${boxName} present in one document only`, + }); + continue; + } + if (boxA.some((v, k) => !within(v, boxB[k] as number))) { + diffs.push({ kind: 'box', page: i + 1, path: boxName, a: [...boxA], b: [...boxB] }); + } + } + + const uuA = readUserUnit(a.reader, pageA); + const uuB = readUserUnit(b.reader, pageB); + if (uuA !== uuB) { + diffs.push({ kind: 'userUnit', page: i + 1, a: uuA, b: uuB }); + } + + const annA = annotationCount(a.reader, i); + const annB = annotationCount(b.reader, i); + if (annA !== annB) { + diffs.push({ + kind: 'annotations', page: i + 1, a: annA, b: annB, + detail: `${annA} vs ${annB} annotation(s)`, + }); + } + } + + // /Info metadata. + const infoA = a.reader.getInfo(); + const infoB = b.reader.getInfo(); + for (const key of INFO_KEYS) { + const va = infoValue(a.reader, infoA, key); + const vb = infoValue(b.reader, infoB, key); + if (va !== vb) { + diffs.push({ kind: 'metadata', path: key, a: va, b: vb }); + } + } + + // Interactive form fields (count + fully-qualified names). + const formA = formSummary(a); + const formB = formSummary(b); + if (!sameJson(formA, formB)) { + diffs.push({ + kind: 'formFields', a: formA, b: formB, + detail: `${formA.count} vs ${formB.count} form field(s)`, + }); + } + + // Encryption (presence + algorithm/revision — never key material). + const encA = encryptionSummary(a); + const encB = encryptionSummary(b); + if (!sameJson(encA, encB)) { + diffs.push({ + kind: 'encryption', a: encA, b: encB, + detail: `${encA === null ? 'not encrypted' : encA.algorithm} vs ${encB === null ? 'not encrypted' : encB.algorithm}`, + }); + } + + // Signature inventory (count, field names, document timestamps). + const sigA = signatureSummary(a); + const sigB = signatureSummary(b); + if (!sameJson(sigA, sigB)) { + diffs.push({ + kind: 'signatures', a: sigA, b: sigB, + detail: `${sigA.count} vs ${sigB.count} signature(s)`, + }); + } + + return diffs; +} + +// ── Text helpers ────────────────────────────────────────────────────── + +function excerpt(line: string): string { + return line.length > EXCERPT_MAX ? `${line.slice(0, EXCERPT_MAX)}…` : line; +} + +function collapseWhitespace(text: string): string { + return text.replace(/\s+/g, ' ').trim(); +} + +/** Extract text for the given 0-based pages, as pageIndex → text. */ +function extractPageTexts(doc: LoadedDoc, pages: readonly number[]): ReadonlyMap { + const opts: { -readonly [K in keyof ExtractTextOptions]: ExtractTextOptions[K] } = { pages: [...pages] }; + if (doc.password !== undefined) opts.password = doc.password; + try { + return new Map(extractText(doc.bytes, opts).map((p) => [p.pageIndex, p.text])); + } catch (e) { + throw mapPdfError(e, `Failed to extract text from PDF ${doc.label}`); + } +} + +function compareText( + a: LoadedDoc, + b: LoadedDoc, + pageIndices: readonly number[], + ignoreWhitespace: boolean, +): Difference[] { + const diffs: Difference[] = []; + const textsA = extractPageTexts(a, pageIndices); + const textsB = extractPageTexts(b, pageIndices); + + for (const idx of pageIndices) { + const ta = textsA.get(idx) ?? ''; + const tb = textsB.get(idx) ?? ''; + + if (ignoreWhitespace) { + const na = collapseWhitespace(ta); + const nb = collapseWhitespace(tb); + if (na !== nb) { + diffs.push({ + kind: 'text', page: idx + 1, + a: excerpt(na), b: excerpt(nb), + detail: 'normalized text differs', + }); + } + continue; + } + + // Simple line-by-line scan (no LCS): report the first differing line. + const linesA = ta.split(/\r?\n/); + const linesB = tb.split(/\r?\n/); + const max = Math.max(linesA.length, linesB.length); + for (let l = 0; l < max; l++) { + if ((linesA[l] ?? '') !== (linesB[l] ?? '')) { + diffs.push({ + kind: 'text', page: idx + 1, + a: excerpt(linesA[l] ?? ''), b: excerpt(linesB[l] ?? ''), + detail: `text differs from line ${l + 1}`, + }); + break; + } + } + } + return diffs; +} + +// ── Report rendering ────────────────────────────────────────────────── + +function renderTextReport( + pathA: string, + pathB: string, + modes: readonly CompareMode[], + diffs: readonly Difference[], +): string { + const lines: string[] = [ + `compare A: ${pathA}`, + ` B: ${pathB}`, + `mode: ${modes.join(', ')}`, + ]; + if (diffs.length === 0) { + lines.push('identical'); + } else { + lines.push(`differences (${diffs.length}):`); + for (const d of diffs) { + const where = (d.page !== undefined ? ` page ${d.page}` : '') + (d.path !== undefined ? ` ${d.path}` : ''); + lines.push(` [${d.kind}]${where}${d.detail !== undefined ? `: ${d.detail}` : ''}`); + if (d.a !== undefined || d.b !== undefined) { + lines.push(` a: ${JSON.stringify(d.a ?? null)}`); + lines.push(` b: ${JSON.stringify(d.b ?? null)}`); + } + } + } + return lines.join('\n') + '\n'; +} + +// ── Command ─────────────────────────────────────────────────────────── + +export async function compare(args: ParsedArgs): Promise { + if (args.positionals.length !== 2) { + throw new CliError( + `compare requires exactly two PDF paths (got ${args.positionals.length}): pdfnative compare .`, + 2, + ); + } + const pathA = args.positionals[0] as string; + const pathB = args.positionals[1] as string; + + const modeRaw = getStringFlag(args.flags, 'mode') ?? 'both'; + const format = getStringFlag(args.flags, 'format') ?? 'text'; + const tolerance = parseTolerance(getStringFlag(args.flags, 'tolerance')); + const ignoreWhitespace = hasFlag(args.flags, 'ignore-whitespace'); + const pagesSpec = getStringFlag(args.flags, 'pages'); + + if (!VALID_MODES.has(modeRaw)) { + throw new CliError(`Invalid --mode value "${modeRaw}". Valid: text, structure, both.`, 2); + } + if (!VALID_FORMATS.has(format)) { + throw new CliError(`Invalid --format value "${format}". Valid: text, json.`, 2); + } + const modes: readonly CompareMode[] = modeRaw === 'both' ? ['structure', 'text'] : [modeRaw as CompareMode]; + + const a = await loadDoc('A', pathA, getStringFlag(args.flags, 'password-a')); + const b = await loadDoc('B', pathB, getStringFlag(args.flags, 'password-b')); + + const diffs: Difference[] = []; + const pcA = a.reader.pageCount; + const pcB = b.reader.pageCount; + if (pcA !== pcB) { + diffs.push({ kind: 'pageCount', a: pcA, b: pcB, detail: `${pcA} vs ${pcB} page(s)` }); + } + const minPages = Math.min(pcA, pcB); + + if (modes.includes('structure')) { + diffs.push(...compareStructure(a, b, tolerance, minPages)); + } + + if (modes.includes('text')) { + // --pages is 1-based (like split/extract); validated against the page + // count both documents share, then deduplicated in selector order. + const pageIndices = pagesSpec !== undefined + ? [...new Set(parsePageList(pagesSpec, minPages))] + : Array.from({ length: minPages }, (_, i) => i); + diffs.push(...compareText(a, b, pageIndices, ignoreWhitespace)); + } + + const equal = diffs.length === 0; + const report = { equal, modes, differences: diffs }; + + if (format === 'json') { + const pretty = hasFlag(args.flags, 'pretty') || !isJsonMode(); + process.stdout.write(serializeJson(report, pretty) + '\n'); + } else { + process.stdout.write(renderTextReport(pathA, pathB, modes, diffs)); + } + + // Differences → exit 1 with E_CHECK_FAILED, AFTER the report reached + // stdout (mirrors `inspect --check`): human mode puts the summary on + // stderr and throws an empty message so the dispatcher does not re-print + // it; agent mode carries the summary in the JSON error envelope. + if (!equal) { + const detail = `documents differ: ${diffs.length} difference(s)`; + if (!isJsonMode()) { + process.stderr.write(detail + '\n'); + throw new CliError('', 1, ErrorCode.CHECK_FAILED); + } + throw new CliError(detail, 1, ErrorCode.CHECK_FAILED); + } + + emitStatus({ command: 'compare', equal: true, modes, differences: 0 }); +} diff --git a/src/commands/completion.ts b/src/commands/completion.ts index ff3ef9f..51f0715 100644 --- a/src/commands/completion.ts +++ b/src/commands/completion.ts @@ -18,7 +18,7 @@ export interface CommandSpec { readonly flags: readonly string[]; } -export const GLOBAL_FLAGS = ['--help', '--version', '--no-color', '--quiet', '--json', '--dry-run', '--config', '--no-config']; +export const GLOBAL_FLAGS = ['--help', '--version', '--no-color', '--quiet', '--json', '--dry-run', '--config', '--no-config', '--max-inflate-size']; // Shared password / re-encryption / streaming flags for the page-tree commands // (merge, split, extract) — pdfnative 1.6.0. @@ -44,7 +44,7 @@ export const COMMANDS: readonly CommandSpec[] = [ '--watermark-position', '--encrypt', '--owner-password', '--user-password', '--permissions', '--encrypt-algorithm', '--encrypt-owner-pass', '--encrypt-user-pass', - '--encrypt-permissions', '--attachment', + '--encrypt-permissions', '--attachment', '--strict', '--chunk-size', ], }, { @@ -53,7 +53,9 @@ export const COMMANDS: readonly CommandSpec[] = [ flags: [ '--input', '--output', '--key', '--cert', '--cert-chain', '--algorithm', '--reason', '--name', '--location', '--contact', '--signing-time', '--timestamp', - '--pure-crypto', + '--timestamp-digest', '--timestamp-nonce', '--allow-multiple', '--field-name', + '--profile', '--digest', '--signature-rect', '--signature-page', + '--placeholder-bytes', '--pure-crypto', ], }, { @@ -61,10 +63,20 @@ export const COMMANDS: readonly CommandSpec[] = [ summary: 'Verify embedded PDF signatures', flags: ['--input', '--trust', '--strict', '--revocation', '--revocation-policy', '--format', '--summary', '--fields', '--pretty'], }, + { + name: 'ltv', + summary: 'PAdES B-LT: collect/embed OCSP+CRL validation data (/DSS)', + flags: ['--input', '--output', '--online', '--prefer', '--extra-cert', '--data', '--timeout'], + }, + { + name: 'doc-timestamp', + summary: 'PAdES B-LTA: append an RFC 3161 document timestamp', + flags: ['--input', '--output', '--url', '--digest', '--field-name', '--placeholder-bytes', '--nonce', '--timeout'], + }, { name: 'inspect', summary: 'Analyse a PDF and output metadata', - flags: ['--input', '--format', '--verbose', '--pages', '--pdfua', '--annotations', '--form-fields', '--encryption', '--password', '--check', '--summary', '--fields', '--pretty'], + flags: ['--input', '--format', '--verbose', '--pages', '--pdfua', '--annotations', '--form-fields', '--encryption', '--signatures', '--password', '--check', '--summary', '--fields', '--pretty'], }, { name: 'merge', @@ -104,12 +116,22 @@ export const COMMANDS: readonly CommandSpec[] = [ { name: 'annotate', summary: 'Attach markup annotations to a PDF', - flags: ['--input', '--output', '--annotations'], + flags: ['--input', '--output', '--annotations', '--password'], + }, + { + name: 'metadata', + summary: 'Update PDF /Info + XMP metadata (incremental — keeps signatures)', + flags: ['--input', '--output', '--title', '--author', '--subject', '--keywords', '--mod-date', '--from-json', '--password'], + }, + { + name: 'compare', + summary: 'Diff two PDFs by text and structure', + flags: ['--mode', '--format', '--tolerance', '--ignore-whitespace', '--pages', '--password-a', '--password-b', '--pretty'], }, { name: 'batch', - summary: 'Render many JSON inputs to PDF in parallel', - flags: ['--input-dir', '--output-dir', '--concurrency', '--fail-fast', '--format', '--layout', '--variant', '--summary', '--fields', '--pretty'], + summary: 'Render a directory or run a multi-command manifest pipeline', + flags: ['--input-dir', '--output-dir', '--concurrency', '--fail-fast', '--manifest', '--allow-network', '--continue-on-error', '--format', '--layout', '--variant', '--summary', '--fields', '--pretty'], }, { name: 'govern', diff --git a/src/commands/docTimestamp.ts b/src/commands/docTimestamp.ts new file mode 100644 index 0000000..5d34bd5 --- /dev/null +++ b/src/commands/docTimestamp.ts @@ -0,0 +1,131 @@ +// `pdfnative doc-timestamp` — append a PAdES B-LTA document timestamp +// (ISO 32000-2 §12.8.5): a `/Type /DocTimeStamp` signature field whose +// `/Contents` is a bare RFC 3161 TimeStampToken covering every byte of the +// current document, added as an incremental update (all earlier revisions +// stay byte-identical). On top of a B-LT document (signature + /DSS) this +// completes the B-LTA archival profile; re-timestamping before the TSA +// certificate expires extends the chain indefinitely. +// +// `--url ` is the explicit network opt-in (offline-by-default doctrine); +// the request goes through the SSRF-guarded TSA transport (src/utils/tsa.ts). + +import { addDocumentTimestamp, getTimestampProvider, ensureCryptoReady } from '../core-bridge/index.js'; +import type { AddDocumentTimestampOptions, CmsDigestAlgorithm } from '../core-bridge/index.js'; +import { type ParsedArgs, getStringFlag, hasFlag } from '../utils/args.js'; +import { readFileOrStdin, writeOutput } from '../utils/io.js'; +import { CliError, ErrorCode } from '../utils/error.js'; +import { emitStatus, isDryRun } from '../utils/agent.js'; +import { createTsaProvider } from '../utils/tsa.js'; + +const VALID_DIGESTS = new Set(['sha256', 'sha384', 'sha512']); + +const DEFAULT_TIMEOUT_MS = 10_000; + +function parseDigest(raw: string | undefined): CmsDigestAlgorithm { + const digest = (raw ?? 'sha256') as CmsDigestAlgorithm; + if (!VALID_DIGESTS.has(digest)) { + throw new CliError(`Invalid --digest "${raw ?? ''}". Valid: sha256, sha384, sha512.`, 2); + } + return digest; +} + +function parseTimeout(raw: string | undefined): number { + if (raw === undefined) return DEFAULT_TIMEOUT_MS; + const n = Number(raw); + if (!Number.isInteger(n) || n <= 0) { + throw new CliError(`Invalid --timeout "${raw}". Expected a positive integer (milliseconds).`, 2); + } + return n; +} + +function parsePlaceholderBytes(raw: string | undefined): number | undefined { + if (raw === undefined) return undefined; + const n = Number(raw); + if (!Number.isInteger(n) || n <= 0) { + throw new CliError(`Invalid --placeholder-bytes "${raw}". Expected a positive integer.`, 2); + } + return n; +} + +function parseNonce(raw: string | undefined): bigint | undefined { + if (raw === undefined) return undefined; + const hex = raw.startsWith('0x') || raw.startsWith('0X') ? raw.slice(2) : raw; + if (hex.length === 0 || !/^[0-9a-fA-F]+$/.test(hex)) { + throw new CliError(`Invalid --nonce "${raw}". Expected a hexadecimal value.`, 2); + } + return BigInt(`0x${hex}`); +} + +/** Validate that the TSA URL is a well-formed http(s) URL. */ +function assertHttpUrl(value: string): void { + let url: URL; + try { + url = new URL(value); + } catch { + throw new CliError(`Invalid --url "${value}".`, 2); + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new CliError(`--url must be an http(s) URL, got "${url.protocol}".`, 2); + } +} + +export async function docTimestamp(args: ParsedArgs): Promise { + const inputPath = getStringFlag(args.flags, 'input', 'i'); + const outputPath = getStringFlag(args.flags, 'output', 'o'); + const url = getStringFlag(args.flags, 'url'); + const digest = parseDigest(getStringFlag(args.flags, 'digest')); + const fieldName = getStringFlag(args.flags, 'field-name'); + const placeholderBytes = parsePlaceholderBytes(getStringFlag(args.flags, 'placeholder-bytes')); + const nonce = parseNonce(getStringFlag(args.flags, 'nonce')); + const timeoutMs = parseTimeout(getStringFlag(args.flags, 'timeout')); + const dryRun = hasFlag(args.flags, 'dry-run') || isDryRun(); + + // `--url` is the explicit network opt-in: the CLI is offline by default, + // and a document timestamp requires an RFC 3161 round-trip to a TSA. + if (url === undefined) { + throw new CliError( + 'doc-timestamp requires --url : appending a document timestamp contacts an ' + + 'RFC 3161 Time-Stamp Authority, and the CLI is offline by default. The explicit ' + + 'URL is the network opt-in.', + 2, + ); + } + assertHttpUrl(url); + + await ensureCryptoReady(); + const pdfBytes = new Uint8Array(await readFileOrStdin(inputPath)); + + // Dry-run: URL and flags validated, PDF read. Stop before any network. + if (dryRun) { + emitStatus({ command: 'doc-timestamp', dryRun: true, digest, output: outputPath ?? '-' }); + return; + } + + const options: { -readonly [K in keyof AddDocumentTimestampOptions]: AddDocumentTimestampOptions[K] } = { + timestampProvider: getTimestampProvider() ?? createTsaProvider(url, { timeoutMs }), + digestAlgorithm: digest, + }; + if (fieldName !== undefined) options.fieldName = fieldName; + if (placeholderBytes !== undefined) options.placeholderBytes = placeholderBytes; + if (nonce !== undefined) options.timestampNonce = nonce; + + let out: Uint8Array; + try { + out = await addDocumentTimestamp(pdfBytes, options); + } catch (e) { + // Network failures surface as CliError E_NETWORK from the transport; + // anything else is a malformed TSA response or PDF — keep it generic + // (a hostile TSA must not inject text into CLI output). + if (e instanceof CliError) throw e; + throw new CliError('Failed to add document timestamp: invalid TSA response or PDF.', 1, ErrorCode.PARSE); + } + + await writeOutput(out, outputPath); + emitStatus({ + command: 'doc-timestamp', + dryRun: false, + digest, + output: outputPath ?? '-', + bytes: out.length, + }); +} diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 4b03c87..7ce0b26 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -24,7 +24,7 @@ interface Check { readonly detail: string; } -const MIN_NODE_MAJOR = 20; +const MIN_NODE_MAJOR = 22; function nodeCheck(): Check { const raw = process.versions.node; diff --git a/src/commands/inspect.ts b/src/commands/inspect.ts index 1dd7d60..be90d75 100644 --- a/src/commands/inspect.ts +++ b/src/commands/inspect.ts @@ -1,5 +1,5 @@ -import { openPdf, validatePdfUA, isStream, readFormFields } from '../core-bridge/index.js'; -import type { PdfReader, PdfUAValidationResult, PageLabelRange, ParsedAnnotation, PdfEncryptionInfo } from '../core-bridge/index.js'; +import { openPdf, validatePdfUA, isStream, readFormFields, listSignatures, nameValue } from '../core-bridge/index.js'; +import type { PdfReader, PdfUAValidationResult, PageLabelRange, ParsedAnnotation, PdfEncryptionInfo, PdfSignatureInfo, PdfDict } from '../core-bridge/index.js'; import { type ParsedArgs, getStringFlag, getStringFlagAll, hasFlag } from '../utils/args.js'; import { readFileOrStdin } from '../utils/io.js'; import { CliError, ErrorCode } from '../utils/error.js'; @@ -9,6 +9,9 @@ import { resolveSourcePassword, mapPdfError } from '../utils/pdfops.js'; const VALID_CHECKS = new Set(['pdfa', 'signed', 'encrypted', 'pdfua']); +/** Parametrized check: `--check "signatures>=N"` (N non-placeholder signatures). */ +const SIG_COUNT_CHECK = /^signatures>=(\d+)$/; + interface PageInfo { readonly index: number; readonly width: number | null; @@ -16,6 +19,29 @@ interface PageInfo { readonly rotation: number; readonly annotations: number; readonly formFields: number; + /** `/CropBox` — viewer display region, when present on the page dict. */ + readonly cropBox?: readonly number[]; + /** `/TrimBox` — finished page size after cutting (ISO 32000-1 §14.11.2). */ + readonly trimBox?: readonly number[]; + /** `/BleedBox` — content clipped in production. */ + readonly bleedBox?: readonly number[]; + /** `/ArtBox` — meaningful-content extent. */ + readonly artBox?: readonly number[]; + /** `/UserUnit` — user-space unit in multiples of 1/72 inch (large-format pages). */ + readonly userUnit?: number; +} + +/** One entry of `inspect --signatures` (pdfnative 1.7.0 `listSignatures`). + * `contentsLength` replaces the raw `/Contents` bytes — key material and + * CMS blobs are never emitted. */ +interface SignatureDetail { + readonly fieldName: string | null; + readonly subFilter: string; + readonly byteRange: readonly number[]; + readonly isDocTimestamp: boolean; + readonly isPlaceholder: boolean; + readonly sigObjNum: number; + readonly contentsLength: number; } interface AnnotationInfo { @@ -60,6 +86,8 @@ interface InspectResult { readonly creationDate: string | null; readonly subject: string | null; readonly producer: string | null; + /** `/Info /Trapped` (ISO 32000-1 §14.11.6) — omitted when absent. */ + readonly trapped?: 'True' | 'False' | 'Unknown'; }; readonly pageLabels?: readonly PageLabelInfo[]; readonly encryption?: EncryptionDetail | null; @@ -148,7 +176,9 @@ function countSignatures(reader: PdfReader): number { let count = 0; for (const ref of fieldsVal) { const field = reader.resolveValue(ref as Parameters[0]); - if (field instanceof Map && field.get('FT') === '/Sig') { + if (!(field instanceof Map)) continue; + const ft = field.get('FT'); + if (ft !== undefined && nameValue(ft) === 'Sig') { count++; } } @@ -158,6 +188,60 @@ function countSignatures(reader: PdfReader): number { } } +/** Read a `[x1 y1 x2 y2]` page-box entry from the page dict, or undefined. + * No /Pages-tree inheritance is attempted — consistent with the MediaBox + * handling above, and the production boxes are not inheritable anyway + * (only MediaBox/CropBox/Rotate/Resources are, ISO 32000-1 §7.7.3.4). */ +function readPageBox(reader: PdfReader, page: PdfDict, key: string): readonly number[] | undefined { + const raw = page.get(key); + if (raw === undefined) return undefined; + let value: unknown; + try { + value = reader.resolveValue(raw as Parameters[0]); + } catch { + return undefined; + } + if (!Array.isArray(value) || value.length !== 4) return undefined; + return value.every((n) => typeof n === 'number' && Number.isFinite(n)) + ? (value as readonly number[]) + : undefined; +} + +/** Read the page's `/UserUnit` (positive finite number), or undefined. */ +function readUserUnit(reader: PdfReader, page: PdfDict): number | undefined { + const raw = page.get('UserUnit'); + if (raw === undefined) return undefined; + let value: unknown; + try { + value = reader.resolveValue(raw as Parameters[0]); + } catch { + return undefined; + } + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined; +} + +/** Read `/Info /Trapped` (a PDF *name*: /True, /False or /Unknown), or undefined. */ +function readTrapped(info: PdfDict | null): 'True' | 'False' | 'Unknown' | undefined { + if (info === null) return undefined; + const raw = info.get('Trapped'); + if (raw === undefined) return undefined; + const v = nameValue(raw); + return v === 'True' || v === 'False' || v === 'Unknown' ? v : undefined; +} + +/** Project a pdfnative signature entry to the stable CLI shape (never the raw /Contents bytes). */ +function toSignatureDetail(s: PdfSignatureInfo): SignatureDetail { + return { + fieldName: s.fieldName ?? null, + subFilter: s.subFilter, + byteRange: s.byteRange, + isDocTimestamp: s.isDocTimestamp, + isPlaceholder: s.isPlaceholder, + sigObjNum: s.sigObjNum, + contentsLength: s.contents.length, + }; +} + function inspectPages(reader: PdfReader): readonly PageInfo[] { const out: PageInfo[] = []; for (let i = 0; i < reader.pageCount; i++) { @@ -183,15 +267,35 @@ function inspectPages(reader: PdfReader): readonly PageInfo[] { annotations++; try { const annot = reader.resolveValue(ref as Parameters[0]); - if (annot instanceof Map && annot.get('Subtype') === '/Widget') { - formFields++; + if (annot instanceof Map) { + const subtype = annot.get('Subtype'); + if (subtype !== undefined && nameValue(subtype) === 'Widget') { + formFields++; + } } } catch { // best-effort — keep counting other annotations } } } - out.push({ index: i, width, height, rotation, annotations, formFields }); + const cropBox = readPageBox(reader, page, 'CropBox'); + const trimBox = readPageBox(reader, page, 'TrimBox'); + const bleedBox = readPageBox(reader, page, 'BleedBox'); + const artBox = readPageBox(reader, page, 'ArtBox'); + const userUnit = readUserUnit(reader, page); + out.push({ + index: i, + width, + height, + rotation, + annotations, + formFields, + ...(cropBox !== undefined ? { cropBox } : {}), + ...(trimBox !== undefined ? { trimBox } : {}), + ...(bleedBox !== undefined ? { bleedBox } : {}), + ...(artBox !== undefined ? { artBox } : {}), + ...(userUnit !== undefined ? { userUnit } : {}), + }); } return out; } @@ -290,19 +394,28 @@ function toInspectSummary(result: InspectResult): Record { }; } -function evaluateChecks(checks: readonly string[], result: InspectResult): CheckResult { +function evaluateChecks( + checks: readonly string[], + result: InspectResult, + signedCount: number, +): CheckResult { const out: { name: string; passed: boolean }[] = []; for (const c of checks) { - if (!VALID_CHECKS.has(c)) { + const sigCount = SIG_COUNT_CHECK.exec(c); + if (!VALID_CHECKS.has(c) && sigCount === null) { throw new CliError( - `Invalid --check value "${c}". Valid: ${[...VALID_CHECKS].join(', ')}.`, + `Invalid --check value "${c}". Valid: ${[...VALID_CHECKS].join(', ')}, signatures>=N.`, 2, ); } if (c === 'pdfa') out.push({ name: c, passed: result.pdfaConformance !== null }); - if (c === 'signed') out.push({ name: c, passed: result.signatures > 0 }); + if (c === 'signed') out.push({ name: c, passed: signedCount > 0 }); if (c === 'encrypted') out.push({ name: c, passed: result.encrypted }); if (c === 'pdfua') out.push({ name: c, passed: result.pdfua?.valid === true }); + if (sigCount !== null) { + const wanted = Number.parseInt(sigCount[1] as string, 10); + out.push({ name: c, passed: signedCount >= wanted }); + } } return { checks: out.map((x) => `${x.name}=${x.passed ? 'pass' : 'fail'}`), @@ -318,6 +431,7 @@ export async function inspect(args: ParsedArgs): Promise { const includeAnnotations = hasFlag(args.flags, 'annotations'); const includeFormFields = hasFlag(args.flags, 'form-fields'); const includeEncryption = hasFlag(args.flags, 'encryption'); + const includeSignatures = hasFlag(args.flags, 'signatures'); const password = resolveSourcePassword(args.flags); const checks = getStringFlagAll(args.flags, 'check'); const includePdfua = hasFlag(args.flags, 'pdfua') || checks.includes('pdfua'); @@ -337,6 +451,7 @@ export async function inspect(args: ParsedArgs): Promise { } const info = reader.getInfo(); + const trapped = readTrapped(info); const baseResult: InspectResult = { version: extractVersion(reader), pageCount: reader.pageCount, @@ -349,9 +464,27 @@ export async function inspect(args: ParsedArgs): Promise { creationDate: info !== null ? safeInfoString(info.get('CreationDate')) : null, subject: info !== null ? safeInfoString(info.get('Subject')) : null, producer: info !== null ? safeInfoString(info.get('Producer')) : null, + ...(trapped !== undefined ? { trapped } : {}), }, }; + // --signatures / signature-count checks: enumerate the signature fields via + // pdfnative 1.7.0 `listSignatures`. Non-placeholder, non-timestamp entries + // are what `--check signed` / `--check "signatures>=N"` count. When only a + // check needs the count and enumeration fails (e.g. an encryption scheme the + // standalone lister cannot open), fall back to the legacy /Sig field count. + const sigCountChecks = checks.filter((c) => c === 'signed' || SIG_COUNT_CHECK.test(c)); + let signatureDetails: readonly SignatureDetail[] | undefined; + let signedCount = baseResult.signatures; + if (includeSignatures || sigCountChecks.length > 0) { + try { + signatureDetails = listSignatures(pdfBytes).map(toSignatureDetail); + signedCount = signatureDetails.filter((s) => !s.isPlaceholder && !s.isDocTimestamp).length; + } catch (e) { + if (includeSignatures) throw mapPdfError(e, 'Failed to list signatures'); + } + } + const pageLabels = inspectPageLabels(reader); let formFields: readonly FormFieldInfo[] | undefined; if (includeFormFields) { @@ -375,7 +508,14 @@ export async function inspect(args: ParsedArgs): Promise { if (format === 'json') { const summary = hasFlag(args.flags, 'summary'); const fieldsRaw = getStringFlag(args.flags, 'fields'); - let out: unknown = summary ? toInspectSummary(result) : result; + // With --signatures the top-level `signatures` field carries the + // detailed entries instead of the bare count (opt-in shape change; the + // --summary verdict keeps its stable numeric `signatures`). + let out: unknown = summary + ? toInspectSummary(result) + : (includeSignatures && signatureDetails !== undefined + ? { ...result, signatures: signatureDetails } + : result); if (fieldsRaw !== undefined) { out = selectFields(out, parseFieldList(fieldsRaw)); } @@ -395,11 +535,33 @@ export async function inspect(args: ParsedArgs): Promise { `Subject: ${result.metadata.subject ?? '—'}`, `Producer: ${result.metadata.producer ?? '—'}`, ]; + if (result.metadata.trapped !== undefined) { + lines.push(`Trapped: ${result.metadata.trapped}`); + } + if (includeSignatures && signatureDetails !== undefined) { + lines.push('Signatures detail:'); + for (let i = 0; i < signatureDetails.length; i++) { + const s = signatureDetails[i] as SignatureDetail; + const tags = [ + s.isDocTimestamp ? 'doc-timestamp' : '', + s.isPlaceholder ? 'placeholder' : '', + ].filter((t) => t !== '').join(', '); + lines.push( + ` #${i + 1} ${s.fieldName ?? '(unnamed)'} [${s.subFilter !== '' ? s.subFilter : '?'}] contents=${s.contentsLength}B obj=${s.sigObjNum}${tags !== '' ? ` (${tags})` : ''}`, + ); + } + } if (result.pages !== undefined) { lines.push('Pages detail:'); for (const p of result.pages) { + const extras: string[] = []; + if (p.cropBox !== undefined) extras.push(`crop=[${p.cropBox.join(' ')}]`); + if (p.trimBox !== undefined) extras.push(`trim=[${p.trimBox.join(' ')}]`); + if (p.bleedBox !== undefined) extras.push(`bleed=[${p.bleedBox.join(' ')}]`); + if (p.artBox !== undefined) extras.push(`art=[${p.artBox.join(' ')}]`); + if (p.userUnit !== undefined) extras.push(`userUnit=${p.userUnit}`); lines.push( - ` #${p.index + 1}: ${p.width ?? '?'}x${p.height ?? '?'}pt rot=${p.rotation}° annots=${p.annotations} fields=${p.formFields}`, + ` #${p.index + 1}: ${p.width ?? '?'}x${p.height ?? '?'}pt rot=${p.rotation}° annots=${p.annotations} fields=${p.formFields}${extras.length > 0 ? ` ${extras.join(' ')}` : ''}`, ); } } @@ -453,7 +615,7 @@ export async function inspect(args: ParsedArgs): Promise { // --check semantics: if any check is given, exit code reflects the result. if (checks.length > 0) { - const evaluation = evaluateChecks(checks, result); + const evaluation = evaluateChecks(checks, result, signedCount); if (!evaluation.allPassed) { const detail = `check failed: ${evaluation.checks.join(', ')}`; // exit 1 = check failure (semantic), distinct from a usage error (2) diff --git a/src/commands/ltv.ts b/src/commands/ltv.ts new file mode 100644 index 0000000..7982c9e --- /dev/null +++ b/src/commands/ltv.ts @@ -0,0 +1,345 @@ +// `pdfnative ltv ` — PAdES B-LT long-term validation +// (ISO 32000-2 §12.8.4): collect OCSP/CRL revocation material for every +// signature and embed it as a /DSS dictionary with per-signature /VRI +// entries, via a non-destructive incremental update. +// +// ltv collect --online Network phase: walk the signatures, fetch OCSP/CRL +// data, emit a portable JSON `ltv-data` document. +// ltv embed --data Offline phase: embed pre-collected ltv-data into +// the PDF. Deterministic, replayable, air-gap safe. +// ltv add --online One-pass convenience: collect + embed. +// +// Offline doctrine: the network phases hard-require the explicit `--online` +// flag; `embed` NEVER touches the network. Every request goes through the +// SSRF-guarded revocation transport (src/utils/ltv-provider.ts). + +import { + collectValidationInfo, + embedValidationInfo, + addValidationInfo, + getRevocationProvider, + ensureCryptoReady, +} from '../core-bridge/index.js'; +import type { LtvData, CollectLtvOptions } from '../core-bridge/index.js'; +import { type ParsedArgs, getStringFlag, getStringFlagAll, hasFlag } from '../utils/args.js'; +import { readFileOrStdin, readBinaryFile, writeOutput, assertJsonSizeLimit } from '../utils/io.js'; +import { CliError, ErrorCode } from '../utils/error.js'; +import { emitStatus, isDryRun } from '../utils/agent.js'; +import { splitPemBlocks, pemToDer } from '../utils/keys.js'; +import { createRevocationProvider } from '../utils/ltv-provider.js'; + +const SUBCOMMANDS = ['collect', 'embed', 'add'] as const; +type LtvMode = (typeof SUBCOMMANDS)[number]; + +/** Version tag of the serialized ltv-data JSON document (`schema ltv-data`). */ +const LTV_DATA_VERSION = 1; + +const DEFAULT_TIMEOUT_MS = 10_000; + +// ── Flag helpers ───────────────────────────────────────────────────── + +function requireOnline(mode: LtvMode, flags: ParsedArgs['flags']): void { + if (hasFlag(flags, 'online')) return; + throw new CliError( + `ltv ${mode} contacts the OCSP responders and CRL distribution points listed in the ` + + 'certificates, and the CLI is offline by default. Pass --online to explicitly opt in ' + + 'to these network requests, or pre-collect the material elsewhere and use ' + + '`ltv embed --data ` — the fully offline, air-gap-safe path.', + 2, + ); +} + +function parseTimeout(raw: string | undefined): number { + if (raw === undefined) return DEFAULT_TIMEOUT_MS; + const n = Number(raw); + if (!Number.isInteger(n) || n <= 0) { + throw new CliError(`Invalid --timeout "${raw}". Expected a positive integer (milliseconds).`, 2); + } + return n; +} + +function parsePrefer(raw: string | undefined): boolean { + if (raw === undefined || raw === 'ocsp') return true; + if (raw === 'crl') return false; + throw new CliError(`Invalid --prefer "${raw}". Valid: ocsp, crl.`, 2); +} + +/** Read every `--extra-cert ` file and decode each PEM block to DER. */ +async function loadExtraCertificates(flags: ParsedArgs['flags']): Promise { + const out: Uint8Array[] = []; + for (const filePath of getStringFlagAll(flags, 'extra-cert')) { + const raw = Buffer.from(await readBinaryFile(filePath)).toString('utf8'); + const blocks = splitPemBlocks(raw); + if (blocks.length === 0) { + throw new CliError(`--extra-cert file "${filePath}" contains no PEM certificate block.`, 1, ErrorCode.INPUT); + } + for (const block of blocks) { + try { + out.push(pemToDer(block)); + } catch { + throw new CliError(`--extra-cert file "${filePath}" contains an invalid PEM block.`, 1, ErrorCode.PARSE); + } + } + } + return out; +} + +// ── ltv-data (de)serialization ─────────────────────────────────────── + +function toBase64(bytes: Uint8Array): string { + return Buffer.from(bytes).toString('base64'); +} + +const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/; + +function fromBase64(value: unknown, field: string): Uint8Array { + if (typeof value !== 'string' || value.length % 4 !== 0 || !BASE64_RE.test(value)) { + throw new CliError(`Invalid ltv-data: "${field}" entries must be valid base64 strings.`, 1, ErrorCode.PARSE); + } + return new Uint8Array(Buffer.from(value, 'base64')); +} + +/** Serialize collected LtvData to the portable `ltv-data` JSON document. */ +function serializeLtvData(data: LtvData): string { + return JSON.stringify( + { + version: LTV_DATA_VERSION, + certificates: data.certificates.map(toBase64), + ocspResponses: data.ocspResponses.map(toBase64), + crls: data.crls.map(toBase64), + vri: data.vri.map((v) => ({ + key: v.key, + certs: v.certs, + ocsps: v.ocsps, + crls: v.crls, + })), + }, + null, + 2, + ); +} + +function indexArray(value: unknown, field: string, max: number): readonly number[] { + if (!Array.isArray(value) || value.some((n) => !Number.isInteger(n) || n < 0 || n >= max)) { + throw new CliError( + `Invalid ltv-data: "${field}" must be an array of indexes into the corresponding collection.`, + 1, + ErrorCode.INPUT, + ); + } + return value as readonly number[]; +} + +/** Strict validation + reconstruction of an ltv-data JSON document. */ +function deserializeLtvData(text: string): LtvData { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + throw new CliError('Invalid ltv-data: not valid JSON.', 1, ErrorCode.PARSE); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new CliError('Invalid ltv-data: expected a JSON object.', 1, ErrorCode.INPUT); + } + const obj = parsed as Record; + if (obj['version'] !== LTV_DATA_VERSION) { + throw new CliError( + `Invalid ltv-data: unsupported version ${JSON.stringify(obj['version'])} (expected ${LTV_DATA_VERSION}).`, + 1, + ErrorCode.INPUT, + ); + } + for (const field of ['certificates', 'ocspResponses', 'crls', 'vri']) { + if (!Array.isArray(obj[field])) { + throw new CliError(`Invalid ltv-data: "${field}" must be an array.`, 1, ErrorCode.INPUT); + } + } + const certificates = (obj['certificates'] as unknown[]).map((v) => fromBase64(v, 'certificates')); + const ocspResponses = (obj['ocspResponses'] as unknown[]).map((v) => fromBase64(v, 'ocspResponses')); + const crls = (obj['crls'] as unknown[]).map((v) => fromBase64(v, 'crls')); + const vri = (obj['vri'] as unknown[]).map((entry) => { + if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) { + throw new CliError('Invalid ltv-data: "vri" entries must be objects.', 1, ErrorCode.INPUT); + } + const e = entry as Record; + if (typeof e['key'] !== 'string' || e['key'].length === 0) { + throw new CliError('Invalid ltv-data: "vri" entries need a non-empty "key" string.', 1, ErrorCode.INPUT); + } + return { + key: e['key'], + certs: indexArray(e['certs'], 'vri.certs', certificates.length), + ocsps: indexArray(e['ocsps'], 'vri.ocsps', ocspResponses.length), + crls: indexArray(e['crls'], 'vri.crls', crls.length), + }; + }); + return { certificates, ocspResponses, crls, vri }; +} + +// ── Collect options shared by `collect` and `add` ──────────────────── + +interface OnlineFlags { + readonly preferOcsp: boolean; + readonly timeoutMs: number; + readonly extraCertificates: readonly Uint8Array[]; +} + +async function readOnlineFlags(args: ParsedArgs): Promise { + return { + preferOcsp: parsePrefer(getStringFlag(args.flags, 'prefer')), + timeoutMs: parseTimeout(getStringFlag(args.flags, 'timeout')), + extraCertificates: await loadExtraCertificates(args.flags), + }; +} + +function buildCollectOptions(flags: OnlineFlags): CollectLtvOptions { + return { + revocationProvider: getRevocationProvider() ?? createRevocationProvider({ timeoutMs: flags.timeoutMs }), + extraCertificates: flags.extraCertificates, + preferOcsp: flags.preferOcsp, + }; +} + +// ── Sub-modes ──────────────────────────────────────────────────────── + +async function ltvCollect(args: ParsedArgs, dryRun: boolean): Promise { + requireOnline('collect', args.flags); + const inputPath = getStringFlag(args.flags, 'input', 'i'); + const outputPath = getStringFlag(args.flags, 'output', 'o'); + const online = await readOnlineFlags(args); + + await ensureCryptoReady(); + const pdfBytes = new Uint8Array(await readFileOrStdin(inputPath)); + + if (dryRun) { + emitStatus({ command: 'ltv', mode: 'collect', dryRun: true, output: outputPath ?? '-' }); + return; + } + + let data: LtvData; + try { + data = await collectValidationInfo(pdfBytes, buildCollectOptions(online)); + } catch (e) { + if (e instanceof CliError) throw e; + throw new CliError('Failed to collect validation information.', 1); + } + + await writeOutput(new TextEncoder().encode(serializeLtvData(data) + '\n'), outputPath); + emitStatus({ + command: 'ltv', + mode: 'collect', + dryRun: false, + output: outputPath ?? '-', + certificates: data.certificates.length, + ocspResponses: data.ocspResponses.length, + crls: data.crls.length, + vri: data.vri.length, + }); +} + +async function ltvEmbed(args: ParsedArgs, dryRun: boolean): Promise { + const inputPath = getStringFlag(args.flags, 'input', 'i'); + const outputPath = getStringFlag(args.flags, 'output', 'o'); + const dataPath = getStringFlag(args.flags, 'data'); + if (dataPath === undefined) { + throw new CliError('ltv embed requires --data (produced by `ltv collect --online`).', 2); + } + + const dataBuf = Buffer.from(await readBinaryFile(dataPath)); + assertJsonSizeLimit(dataBuf); + const data = deserializeLtvData(dataBuf.toString('utf8')); + + const pdfBytes = new Uint8Array(await readFileOrStdin(inputPath)); + + if (dryRun) { + emitStatus({ + command: 'ltv', + mode: 'embed', + dryRun: true, + output: outputPath ?? '-', + certificates: data.certificates.length, + ocspResponses: data.ocspResponses.length, + crls: data.crls.length, + vri: data.vri.length, + }); + return; + } + + let out: Uint8Array; + try { + out = embedValidationInfo(pdfBytes, data); + } catch (e) { + if (e instanceof CliError) throw e; + throw new CliError('Failed to embed validation information.', 1); + } + + await writeOutput(out, outputPath); + emitStatus({ + command: 'ltv', + mode: 'embed', + dryRun: false, + output: outputPath ?? '-', + certificates: data.certificates.length, + ocspResponses: data.ocspResponses.length, + crls: data.crls.length, + vri: data.vri.length, + bytes: out.length, + }); +} + +async function ltvAdd(args: ParsedArgs, dryRun: boolean): Promise { + requireOnline('add', args.flags); + const inputPath = getStringFlag(args.flags, 'input', 'i'); + const outputPath = getStringFlag(args.flags, 'output', 'o'); + const online = await readOnlineFlags(args); + + await ensureCryptoReady(); + const pdfBytes = new Uint8Array(await readFileOrStdin(inputPath)); + + if (dryRun) { + emitStatus({ command: 'ltv', mode: 'add', dryRun: true, output: outputPath ?? '-' }); + return; + } + + let out: Uint8Array; + try { + out = await addValidationInfo(pdfBytes, buildCollectOptions(online)); + } catch (e) { + if (e instanceof CliError) throw e; + throw new CliError('Failed to add validation information.', 1); + } + + await writeOutput(out, outputPath); + emitStatus({ + command: 'ltv', + mode: 'add', + dryRun: false, + output: outputPath ?? '-', + bytes: out.length, + }); +} + +// ── Dispatch ───────────────────────────────────────────────────────── + +export async function ltv(args: ParsedArgs): Promise { + const sub = args.positionals[0]; + if (sub === undefined) { + throw new CliError(`Usage: pdfnative ltv <${SUBCOMMANDS.join('|')}>`, 2); + } + const dryRun = hasFlag(args.flags, 'dry-run') || isDryRun(); + switch (sub) { + case 'collect': + await ltvCollect(args, dryRun); + return; + case 'embed': + await ltvEmbed(args, dryRun); + return; + case 'add': + await ltvAdd(args, dryRun); + return; + default: + throw new CliError( + `Unknown ltv subcommand "${sub}". Valid: ${SUBCOMMANDS.join(', ')}.`, + 2, + ); + } +} diff --git a/src/commands/metadata.ts b/src/commands/metadata.ts new file mode 100644 index 0000000..f334624 --- /dev/null +++ b/src/commands/metadata.ts @@ -0,0 +1,183 @@ +// `pdfnative metadata` — update a PDF's document metadata (/Info dictionary, +// mirrored to the XMP packet when the document carries one) through pdfnative's +// INCREMENTAL modifier (ISO 32000-1 §7.5.6). New objects are appended after the +// original bytes — the previous revision stays byte-for-byte intact, so any +// existing digital signature remains valid for its revision. That preservation +// is the reason this command exists instead of a rebuild. +// +// Fields come either from per-field flags (--title/--author/--subject/ +// --keywords/--mod-date) or from a JSON object (--from-json), never both. +// Without --mod-date the library stamps the current instant into /ModDate +// (non-deterministic output); pass a fixed ISO 8601 date for reproducible bytes. +// +// Encrypted sources: the document is OPENED with --password (or +// $PDFNATIVE_PASSWORD) via openPdf's password support. Note that pdfnative's +// incremental modifier does not document re-encrypting appended objects (unlike +// `fillForm`), so writing metadata into an encrypted PDF may not be supported +// end-to-end by the engine yet. + +import { openPdf, createModifier } from '../core-bridge/index.js'; +import type { PdfMetadataUpdate, PdfReader } from '../core-bridge/index.js'; +import { type ParsedArgs, getStringFlag, hasFlag } from '../utils/args.js'; +import { readFileOrStdin, readBinaryFile, writeOutput, assertJsonSizeLimit } from '../utils/io.js'; +import { CliError, ErrorCode } from '../utils/error.js'; +import { emitStatus, isDryRun } from '../utils/agent.js'; +import { resolveSourcePassword, mapPdfError } from '../utils/pdfops.js'; + +type MutableMetadataUpdate = { -readonly [K in keyof PdfMetadataUpdate]: PdfMetadataUpdate[K] }; + +/** The /Info string fields settable per-flag and via --from-json. */ +const STRING_FIELDS = ['title', 'author', 'subject', 'keywords'] as const; + +/** Parse an ISO 8601 date given on the command line (`--mod-date`). Usage error on failure. */ +function parseModDateFlag(raw: string): Date { + const t = new Date(raw); + if (Number.isNaN(t.getTime())) { + throw new CliError(`Invalid --mod-date "${raw}". Expected ISO 8601 (e.g. 2026-01-15T00:00:00Z).`, 2); + } + return t; +} + +/** Parse the `modDate` string inside a --from-json payload. Input error on failure. */ +function parseModDateJson(raw: string): Date { + const t = new Date(raw); + if (Number.isNaN(t.getTime())) { + throw new CliError( + `Invalid modDate "${raw}" in --from-json. Expected an ISO 8601 string.`, + 1, + ErrorCode.INPUT, + ); + } + return t; +} + +/** + * Load and validate a --from-json payload: a single object with any of + * { title, author, subject, keywords, modDate } — all strings, `modDate` an + * ISO 8601 instant. Unknown keys are rejected (E_INPUT) so typos never pass + * silently. + */ +async function loadUpdateFromJson(path: string): Promise { + let buf: Uint8Array; + try { + buf = await readBinaryFile(path); + } catch (e) { + if (e instanceof CliError) throw e; + const message = e instanceof Error ? e.message : String(e); + throw new CliError(`Cannot read --from-json file "${path}": ${message}`, 1, ErrorCode.IO); + } + assertJsonSizeLimit(Buffer.from(buf)); + + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder('utf-8', { fatal: false }).decode(buf)); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + throw new CliError(`Failed to parse --from-json JSON: ${message}`, 1, ErrorCode.PARSE); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new CliError( + '--from-json must be a JSON object: { "title"?, "author"?, "subject"?, "keywords"?, "modDate"? }.', + 1, + ErrorCode.INPUT, + ); + } + + const obj = parsed as Record; + const update: MutableMetadataUpdate = {}; + for (const key of Object.keys(obj)) { + const value = obj[key]; + if ((STRING_FIELDS as readonly string[]).includes(key)) { + if (typeof value !== 'string') { + throw new CliError(`--from-json field "${key}" must be a string.`, 1, ErrorCode.INPUT); + } + update[key as (typeof STRING_FIELDS)[number]] = value; + } else if (key === 'modDate') { + if (typeof value !== 'string') { + throw new CliError('--from-json field "modDate" must be an ISO 8601 string.', 1, ErrorCode.INPUT); + } + update.modDate = parseModDateJson(value); + } else { + throw new CliError( + `Unknown key "${key}" in --from-json. Valid: ${[...STRING_FIELDS, 'modDate'].join(', ')}.`, + 1, + ErrorCode.INPUT, + ); + } + } + return update; +} + +export async function metadata(args: ParsedArgs): Promise { + const inputPath = getStringFlag(args.flags, 'input', 'i'); + const outputPath = getStringFlag(args.flags, 'output', 'o'); + const fromJsonPath = getStringFlag(args.flags, 'from-json'); + const password = resolveSourcePassword(args.flags); + const dryRun = hasFlag(args.flags, 'dry-run') || isDryRun(); + + // Per-field flags. Validate scalar flags up-front so usage errors (exit 2) + // are reported before any I/O (same convention as `sign`). + const flagUpdate: MutableMetadataUpdate = {}; + for (const key of STRING_FIELDS) { + const value = getStringFlag(args.flags, key); + if (value !== undefined) flagUpdate[key] = value; + } + const modDateRaw = getStringFlag(args.flags, 'mod-date'); + if (modDateRaw !== undefined) flagUpdate.modDate = parseModDateFlag(modDateRaw); + + const hasFieldFlags = Object.keys(flagUpdate).length > 0; + if (fromJsonPath !== undefined && hasFieldFlags) { + throw new CliError( + '--from-json is mutually exclusive with the per-field flags (--title, --author, --subject, --keywords, --mod-date).', + 2, + ); + } + + const update: MutableMetadataUpdate = fromJsonPath !== undefined + ? await loadUpdateFromJson(fromJsonPath) + : flagUpdate; + + const fields = Object.keys(update); + if (fields.length === 0) { + throw new CliError( + 'metadata requires at least one field: --title, --author, --subject, --keywords, --mod-date, or --from-json .', + 2, + ); + } + + const inputBuf = await readFileOrStdin(inputPath); + const pdfBytes = new Uint8Array(inputBuf); + + let reader: PdfReader; + try { + reader = openPdf(pdfBytes, password !== undefined ? { password } : undefined); + } catch (e) { + throw mapPdfError(e, 'Failed to read PDF'); + } + + if (dryRun) { + emitStatus({ command: 'metadata', dryRun: true, output: outputPath ?? '-', fields }); + return; + } + + const modifier = createModifier(reader); + let outBytes: Uint8Array; + try { + // When `modDate` is absent the library stamps the current instant — + // exactly the documented default — so we never synthesize one here. + modifier.updateMetadata(update); + outBytes = modifier.save(); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + throw new CliError(`Failed to write updated PDF: ${message}`, 1, ErrorCode.RUNTIME); + } + + await writeOutput(outBytes, outputPath); + emitStatus({ + command: 'metadata', + dryRun: false, + output: outputPath ?? '-', + fields, + bytes: outBytes.length, + }); +} diff --git a/src/commands/render.ts b/src/commands/render.ts index 36385f9..0827edf 100644 --- a/src/commands/render.ts +++ b/src/commands/render.ts @@ -1,5 +1,6 @@ import { watchFile, unwatchFile } from 'node:fs'; -import { resolve as resolvePath, dirname, join as joinPath } from 'node:path'; +import { readFile } from 'node:fs/promises'; +import { resolve as resolvePath, dirname, isAbsolute, join as joinPath } from 'node:path'; import { pathToFileURL } from 'node:url'; import { createRequire } from 'node:module'; import { @@ -24,6 +25,8 @@ import type { PdfColor, FontEntry, OutlineItem, + PdfDiagnosticHandler, + StreamOptions, } from '../core-bridge/index.js'; import { type ParsedArgs, @@ -37,7 +40,9 @@ import { writeOutput, writeStreamingOutput, assertJsonSizeLimit, + validatePath, } from '../utils/io.js'; +import { parseChunkSize } from '../utils/pdfops.js'; import { CliError, ErrorCode } from '../utils/error.js'; import { emitStatus, isDryRun, isJsonMode } from '../utils/agent.js'; import { serializeJson } from '../utils/projection.js'; @@ -137,6 +142,135 @@ function hasTocBlock(params: DocumentParams): boolean { return false; } +// ── Conformance diagnostics + build-error mapping (pdfnative 1.7.0) ────── + +/** True when the global `--quiet`/`-q` flag is active (set by index.ts). */ +function isQuiet(): boolean { + return process.env['PDFNATIVE_QUIET'] === '1'; +} + +/** One diagnostic captured for the `--json` status envelope. */ +interface CollectedDiagnostic { + readonly code: string; + readonly severity: string; + readonly message: string; +} + +/** + * Map errors thrown by pdfnative's builders to stable CLI error codes: + * - strict-mode PDF/A diagnostic escalations (prefixed `pdfnative: ` by + * `createDiagnosticEmitter`, thrown before the first output byte) → + * exit 1 / `E_CHECK_FAILED` — a conformance check failed, by request. + * - option-validation errors (`print.*` from validatePrintOptions — + * including `print.userUnit` under pdfa1b — `chart:` from the chart + * validators, `outputIntent.*` from the ICC profile guard) → + * exit 1 / `E_INPUT` — the input JSON/layout asked for something invalid. + * - anything else is rethrown unchanged (envelope default: `E_RUNTIME`). + */ +function mapBuildError(e: unknown, strict: boolean): never { + if (e instanceof CliError) throw e; + const message = e instanceof Error ? e.message : String(e); + if (strict && message.startsWith('pdfnative:')) { + throw new CliError(message, 1, ErrorCode.CHECK_FAILED); + } + if ( + message.startsWith('print.') || + message.startsWith('chart:') || + message.startsWith('outputIntent.') + ) { + throw new CliError(message, 1, ErrorCode.INPUT); + } + throw e instanceof Error ? e : new Error(message); +} + +// ── Image-block payload resolution (CLI JSON convenience) ──────────────── + +const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/; + +/** Decode an image block `dataBase64` payload; invalid base64 → E_INPUT. */ +function decodeImageBase64(b64: string): Uint8Array { + const cleaned = b64.replace(/\s+/g, ''); + if (cleaned.length === 0 || cleaned.length % 4 !== 0 || !BASE64_RE.test(cleaned)) { + throw new CliError( + 'Invalid base64 payload in image block "dataBase64".', + 1, + ErrorCode.INPUT, + ); + } + const buf = Buffer.from(cleaned, 'base64'); + return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); +} + +/** + * Resolve document `image` blocks whose payload arrives as a file path + * (`src`), base64 (`dataBase64`), or a JSON number array (`data`) into the + * `data: Uint8Array` shape pdfnative's `ImageBlock` requires. `src` and + * `dataBase64` are removed from the block after resolution. + * + * Security: `src` is only ever read from the user's own local input JSON — + * the same trust model as `--attachment ` — and passes through the + * same `validatePath` traversal guard. Relative paths resolve against the + * directory of the `--input` file (or the cwd when reading stdin). + * + * `DocumentBlock` has no nested block containers (list items nest text, not + * blocks), so a flat pass over `params.blocks` covers every image block. + */ +async function resolveImageBlocks( + params: DocumentParams, + baseDir: string, +): Promise { + let touched = false; + const blocks: unknown[] = []; + for (const b of params.blocks) { + const block = b as { type?: unknown } & Record; + if (block.type !== 'image' || block.data instanceof Uint8Array) { + blocks.push(b); + continue; + } + const { src, dataBase64, data } = block; + const resolved: Record = { ...block }; + delete resolved.src; + delete resolved.dataBase64; + if (Array.isArray(data)) { + // JSON round-trip of a Uint8Array — revive it. + resolved.data = Uint8Array.from(data as number[]); + } else if (typeof dataBase64 === 'string') { + if (typeof src === 'string') { + throw new CliError( + 'Image block cannot carry both "src" and "dataBase64" — provide a single payload source.', + 1, + ErrorCode.INPUT, + ); + } + resolved.data = decodeImageBase64(dataBase64); + } else if (typeof src === 'string') { + validatePath(src); + const abs = isAbsolute(src) ? src : resolvePath(baseDir, src); + try { + const buf = await readFile(abs); + resolved.data = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + throw new CliError( + `Failed to read image block src "${src}": ${msg}`, + 1, + ErrorCode.IO, + ); + } + } else { + throw new CliError( + 'Image block requires a payload: "data" (byte array), "dataBase64" (base64 string), or "src" (image file path).', + 1, + ErrorCode.INPUT, + ); + } + touched = true; + blocks.push(resolved); + } + if (!touched) return params; + return { ...params, blocks: blocks as unknown as DocumentParams['blocks'] }; +} + // ── Smart-table defaults (pdfnative 1.2.0) ─────────────────────────────── /** @@ -335,6 +469,8 @@ interface RenderConfig { readonly inspectLayout: boolean; readonly pretty: boolean; readonly dryRun: boolean; + /** `--chunk-size` (bytes) for --stream / --stream-true StreamOptions. */ + readonly chunkSize: number | undefined; } /** Parse `--outline`: `auto` selects heading-derived bookmarks; any other value @@ -390,6 +526,25 @@ async function renderOnce(cfg: RenderConfig, template: unknown): Promise { parsedInput = deepMerge(template, parsedInput); } + // Conformance diagnostics (pdfnative 1.7.0): always install a sink so + // warnings reach stderr (never console.warn) and the --json envelope. + // JSON layouts cannot carry functions, so this never clobbers user config. + // In strict mode the library ignores the handler — diagnostics throw + // before the first output byte instead (mapped to E_CHECK_FAILED below). + const diagnostics: CollectedDiagnostic[] = []; + const onDiagnostic: PdfDiagnosticHandler = (d) => { + diagnostics.push({ code: d.code, severity: d.severity, message: d.message }); + if (!isQuiet()) { + process.stderr.write(`warning: [${d.code}] ${d.message}\n`); + } + }; + const diagnosticsField = (): Record => + diagnostics.length > 0 ? { diagnostics } : {}; + + // --chunk-size → StreamOptions for the single-pass streaming builders. + const streamOpts: StreamOptions | undefined = + cfg.chunkSize !== undefined ? { chunkSize: cfg.chunkSize } : undefined; + if (cfg.variant === 'table') { if (!isPdfParamsLike(parsedInput)) { throw new CliError( @@ -402,22 +557,31 @@ async function renderOnce(cfg: RenderConfig, template: unknown): Promise { emitStatus({ command: 'render', variant: 'table', dryRun: true, output: cfg.outputPath ?? '-' }); return; } + const tableLayout: Partial = { ...cfg.layout, onDiagnostic }; let bytes: number | null = null; - if (cfg.usePageStream) { - const generator = buildPDFStreamPageByPage(parsedInput, cfg.layout); - await writeStreamingOutput(generator, cfg.outputPath); - } else if (cfg.useStreamTrue) { - const generator = buildPDFStreamTrue(parsedInput, cfg.layout); - await writeStreamingOutput(generator, cfg.outputPath); - } else if (cfg.useStream) { - const generator = buildPDFStream(parsedInput, cfg.layout); - await writeStreamingOutput(generator, cfg.outputPath); - } else { - const pdfBytes = buildPDFBytes(parsedInput, cfg.layout); - bytes = pdfBytes.length; - await writeOutput(pdfBytes, cfg.outputPath); + try { + if (cfg.usePageStream) { + const generator = buildPDFStreamPageByPage(parsedInput, tableLayout); + await writeStreamingOutput(generator, cfg.outputPath); + } else if (cfg.useStreamTrue) { + const generator = buildPDFStreamTrue(parsedInput, tableLayout, streamOpts); + await writeStreamingOutput(generator, cfg.outputPath); + } else if (cfg.useStream) { + const generator = buildPDFStream(parsedInput, tableLayout, streamOpts); + await writeStreamingOutput(generator, cfg.outputPath); + } else { + const pdfBytes = buildPDFBytes(parsedInput, tableLayout); + bytes = pdfBytes.length; + await writeOutput(pdfBytes, cfg.outputPath); + } + } catch (e) { + mapBuildError(e, tableLayout.strict === true); } - emitStatus({ command: 'render', variant: 'table', dryRun: false, output: cfg.outputPath ?? '-', bytes }); + emitStatus({ + command: 'render', variant: 'table', dryRun: false, + output: cfg.outputPath ?? '-', bytes, + ...diagnosticsField(), + }); return; } @@ -436,6 +600,14 @@ async function renderOnce(cfg: RenderConfig, template: unknown): Promise { params = applyTableDefaults(params, cfg.tableDefaults); } + // Image blocks: resolve `src` / `dataBase64` / number-array payloads to + // the `data: Uint8Array` the builder expects. Relative `src` paths are + // resolved against the --input file's directory (cwd for stdin input). + const imageBaseDir = cfg.inputPath !== undefined + ? dirname(resolvePath(cfg.inputPath)) + : process.cwd(); + params = await resolveImageBlocks(params, imageBaseDir); + // --outline (pdfnative 1.4.0 bookmarks). Flag wins over any JSON-embedded // outline so the CLI stays authoritative. if (cfg.outline !== undefined) { @@ -453,11 +625,13 @@ async function renderOnce(cfg: RenderConfig, template: unknown): Promise { // CLI flags / --layout file (already in `layout`) override on top. // pdfnative uses `layoutOptions ?? params.layout` — an empty object from // the CLI side is not nullish, so params.layout would be silently dropped - // without this explicit merge. - const effectiveLayout: Partial = - params.layout !== undefined && params.layout !== null - ? { ...params.layout, ...cfg.layout } - : cfg.layout; + // without this explicit merge. A `strict` set in the user's JSON survives + // (the CLI only ever layers `strict: true` on top, never `false`). + const effectiveLayout: Partial = { + ...(params.layout ?? {}), + ...cfg.layout, + onDiagnostic, + }; // --inspect-layout (pdfnative 1.5.0): emit the deterministic layout report // as JSON instead of rendering a PDF. A read-only pre-flight for agents. @@ -497,27 +671,35 @@ async function renderOnce(cfg: RenderConfig, template: unknown): Promise { } let bytes: number | null = null; - if (cfg.usePageStream) { - // Page-by-page streaming assembles the full PDF, then chunks it at PDF - // object boundaries — so TOC blocks and {pages} placeholders are fully - // supported (unlike single-pass --stream). - const generator = buildDocumentPDFStreamPageByPage(params, effectiveLayout); - await writeStreamingOutput(generator, cfg.outputPath); - } else if (cfg.useStreamTrue) { - // True constant-memory streaming: parts are emitted and freed as they - // go, so the joined binary never materialises. Same constraints as - // --stream (no TOC, no {pages}); byte-identical to buildDocumentPDFBytes. - const generator = buildDocumentPDFStreamTrue(params, effectiveLayout); - await writeStreamingOutput(generator, cfg.outputPath); - } else if (cfg.useStream) { - const generator = buildDocumentPDFStream(params, effectiveLayout); - await writeStreamingOutput(generator, cfg.outputPath); - } else { - const pdfBytes = buildDocumentPDFBytes(params, effectiveLayout); - bytes = pdfBytes.length; - await writeOutput(pdfBytes, cfg.outputPath); - } - emitStatus({ command: 'render', variant: 'document', dryRun: false, output: cfg.outputPath ?? '-', bytes }); + try { + if (cfg.usePageStream) { + // Page-by-page streaming assembles the full PDF, then chunks it at PDF + // object boundaries — so TOC blocks and {pages} placeholders are fully + // supported (unlike single-pass --stream). + const generator = buildDocumentPDFStreamPageByPage(params, effectiveLayout); + await writeStreamingOutput(generator, cfg.outputPath); + } else if (cfg.useStreamTrue) { + // True constant-memory streaming: parts are emitted and freed as they + // go, so the joined binary never materialises. Same constraints as + // --stream (no TOC, no {pages}); byte-identical to buildDocumentPDFBytes. + const generator = buildDocumentPDFStreamTrue(params, effectiveLayout, streamOpts); + await writeStreamingOutput(generator, cfg.outputPath); + } else if (cfg.useStream) { + const generator = buildDocumentPDFStream(params, effectiveLayout, streamOpts); + await writeStreamingOutput(generator, cfg.outputPath); + } else { + const pdfBytes = buildDocumentPDFBytes(params, effectiveLayout); + bytes = pdfBytes.length; + await writeOutput(pdfBytes, cfg.outputPath); + } + } catch (e) { + mapBuildError(e, effectiveLayout.strict === true); + } + emitStatus({ + command: 'render', variant: 'document', dryRun: false, + output: cfg.outputPath ?? '-', bytes, + ...diagnosticsField(), + }); } export async function render(args: ParsedArgs): Promise { @@ -536,6 +718,8 @@ export async function render(args: ParsedArgs): Promise { const outlineSpec = getStringFlag(args.flags, 'outline'); const inspectLayout = hasFlag(args.flags, 'inspect-layout'); const pretty = hasFlag(args.flags, 'pretty'); + const strict = hasFlag(args.flags, 'strict'); + const chunkSize = parseChunkSize(getStringFlag(args.flags, 'chunk-size')); if (!VALID_VARIANTS.has(variant)) { throw new CliError( @@ -551,6 +735,16 @@ export async function render(args: ParsedArgs): Promise { ); } + // --chunk-size targets the single-pass streaming builders (StreamOptions). + // Page-by-page streaming cuts chunks at PDF object boundaries by design, + // so a byte-size override would be a silent no-op — reject it explicitly. + if (chunkSize !== undefined && usePageStream) { + throw new CliError( + '--chunk-size is not supported with --stream-page-by-page (chunks are cut at PDF object boundaries). Use --stream or --stream-true.', + 2, + ); + } + if (useWatch) { if (inputPath === undefined) { throw new CliError('--watch requires --input (cannot watch stdin).', 2); @@ -560,7 +754,10 @@ export async function render(args: ParsedArgs): Promise { } } - const layout = await buildLayoutOptions(args); + let layout = await buildLayoutOptions(args); + // --strict only ever layers `strict: true` on top — a `strict` already set + // in a --layout file (or the input JSON's `layout`) is never overwritten. + if (strict) layout = { ...layout, strict: true }; if (useStream || useStreamTrue) assertStreamingCompatible(layout); if (layout.compress === true) { @@ -596,6 +793,7 @@ export async function render(args: ParsedArgs): Promise { inspectLayout, pretty, dryRun, + chunkSize, }; // Initial render (always runs, even in --watch mode). diff --git a/src/commands/schema.ts b/src/commands/schema.ts index 82afaf9..6bc1d51 100644 --- a/src/commands/schema.ts +++ b/src/commands/schema.ts @@ -30,6 +30,10 @@ const SUBJECTS = [ 'verify-summary', 'batch-summary', 'govern-verify', + 'metadata', + 'ltv-data', + 'compare', + 'batch-manifest', 'status', 'manifest', 'doctor', @@ -53,10 +57,38 @@ function renderSchema(): JsonSchema { properties: { blocks: { type: 'array', - description: 'Ordered document blocks (text, table, image, toc, …).', + description: 'Ordered document blocks (heading, paragraph, table, list, ' + + 'spacer, pageBreak, image, link, toc, barcode, svg, formField, ' + + 'chart). Chart blocks support 9 kinds (bar, barH, line, pie, ' + + 'donut, stackedBar, stackedBarH, area, scatter) plus xValues, ' + + 'yAxis "left"|"right", axis.scale "linear"|"log", axis2, xAxis ' + + '{type: "category"|"linear"|"time"}, dataLabels, labelStride and ' + + 'labelRotation (pdfnative 1.7.0). Image blocks accept "src" (a ' + + 'path resolved against the --input JSON\'s directory), ' + + '"dataBase64" (inline base64 JPEG/PNG), or "data" (byte array) — ' + + 'the CLI resolves them to bytes before rendering.', items: { type: 'object' }, }, - layout: { type: 'object', description: 'PdfLayoutOptions overrides.' }, + layout: { + type: 'object', + description: 'PdfLayoutOptions overrides. Includes print production ' + + '(print: {bleed, trimBox, bleedBox, artBox, cropBox, marks, ' + + 'userUnit}), outputIntent ({iccProfile: number[], ' + + 'outputConditionIdentifier, …}, ICC RGB), viewerPreferences ' + + '(duplex, pickTrayByPDFSize, printPageRange [[first,last]…], ' + + 'numCopies) and strict (escalate PDF/A diagnostics to errors) — ' + + 'pdfnative 1.7.0.', + }, + metadata: { + type: 'object', + description: 'Document metadata → /Info + XMP (pdfnative 1.7.0).', + properties: { + author: { type: 'string' }, + subject: { type: 'string' }, + keywords: { type: 'string' }, + trapped: { type: 'string', enum: ['True', 'False', 'Unknown'] }, + }, + }, fontEntries: { type: 'array', description: 'Pre-registered font entries (usually set via --font/--lang).', @@ -73,6 +105,16 @@ function renderSchema(): JsonSchema { title: { type: 'string' }, headers: { type: 'array', items: { type: 'string' } }, rows: { type: 'array', items: { type: 'array' } }, + metadata: { + type: 'object', + description: 'Document metadata → /Info + XMP (pdfnative 1.7.0).', + properties: { + author: { type: 'string' }, + subject: { type: 'string' }, + keywords: { type: 'string' }, + trapped: { type: 'string', enum: ['True', 'False', 'Unknown'] }, + }, + }, }, }; return { @@ -99,7 +141,29 @@ function inspectSchema(): JsonSchema { pageCount: { type: 'integer', minimum: 0 }, encrypted: { type: 'boolean' }, pdfaConformance: { type: ['string', 'null'] }, - signatures: { type: 'integer', minimum: 0 }, + signatures: { + description: 'Signature count, or — with `inspect --signatures` ' + + '(pdfnative 1.7.0) — the detailed signature-field list (never ' + + 'the signature bytes).', + oneOf: [ + { type: 'integer', minimum: 0 }, + { + type: 'array', + items: { + type: 'object', + properties: { + fieldName: { type: ['string', 'null'] }, + subFilter: { type: 'string' }, + byteRange: { type: 'array', items: { type: 'integer' } }, + isDocTimestamp: { type: 'boolean' }, + isPlaceholder: { type: 'boolean' }, + sigObjNum: { type: 'integer' }, + contentsLength: { type: 'integer' }, + }, + }, + }, + ], + }, metadata: { type: 'object', additionalProperties: false, @@ -109,6 +173,7 @@ function inspectSchema(): JsonSchema { creationDate: { type: ['string', 'null'] }, subject: { type: ['string', 'null'] }, producer: { type: ['string', 'null'] }, + trapped: { type: 'string', enum: ['True', 'False', 'Unknown'] }, }, }, pages: { @@ -122,6 +187,11 @@ function inspectSchema(): JsonSchema { rotation: { type: 'number' }, annotations: { type: 'integer' }, formFields: { type: 'integer' }, + cropBox: { type: 'array', minItems: 4, maxItems: 4, items: { type: 'number' } }, + trimBox: { type: 'array', minItems: 4, maxItems: 4, items: { type: 'number' } }, + bleedBox: { type: 'array', minItems: 4, maxItems: 4, items: { type: 'number' } }, + artBox: { type: 'array', minItems: 4, maxItems: 4, items: { type: 'number' } }, + userUnit: { type: 'number' }, }, }, }, @@ -227,7 +297,18 @@ function verifySchema(): JsonSchema { chainValid: { type: 'boolean' }, trustedRoot: { type: 'boolean' }, signatureValid: { type: 'boolean' }, - signatureAlgorithm: { type: ['string', 'null'], enum: ['rsa-sha256', 'ecdsa-sha256', null] }, + signatureAlgorithm: { + type: ['string', 'null'], + enum: ['rsa-sha256', 'rsa-sha384', 'rsa-sha512', 'ecdsa-sha256', + 'ecdsa-sha384', 'ecdsa-sha512', null], + description: 'ecdsa-sha384/512 are detected and labelled but ' + + 'never verify (pdfnative verification is P-256 + SHA-256 only).', + }, + isDocTimestamp: { + type: 'boolean', + description: 'True for /DocTimeStamp revisions (SubFilter ' + + 'ETSI.RFC3161, PAdES B-LTA) — validated as RFC 3161 tokens.', + }, timestampPresent: { type: 'boolean' }, timestampValid: { type: 'boolean' }, timestampTime: { type: ['string', 'null'] }, @@ -250,14 +331,21 @@ function batchSchema(): JsonSchema { $schema: DRAFT, $id: id('batch'), title: 'pdfnative-cli batch output', - description: 'JSON emitted by `pdfnative batch --format json`.', + description: 'JSON emitted by `pdfnative batch --format json`. Directory mode ' + + 'reports per-file `results`; manifest mode (`--manifest`, pdfnative-cli ' + + '1.4.0) reports per-task `tasks` plus `mode: "manifest"` and `skipped`.', type: 'object', - required: ['total', 'succeeded', 'failed', 'results'], + required: ['total', 'succeeded', 'failed'], additionalProperties: false, properties: { + ok: { type: 'boolean', description: 'Manifest mode only.' }, + command: { const: 'batch', description: 'Manifest mode only.' }, total: { type: 'integer', minimum: 0 }, succeeded: { type: 'integer', minimum: 0 }, failed: { type: 'integer', minimum: 0 }, + skipped: { type: 'integer', minimum: 0 }, + mode: { type: 'string', enum: ['manifest'] }, + dryRun: { type: 'boolean' }, results: { type: 'array', items: { @@ -272,6 +360,27 @@ function batchSchema(): JsonSchema { }, }, }, + tasks: { + type: 'array', + items: { + type: 'object', + required: ['id', 'command', 'ok'], + properties: { + id: { type: 'string' }, + command: { type: 'string' }, + ok: { type: 'boolean' }, + output: { type: 'string' }, + skipped: { type: 'boolean' }, + error: { + type: 'object', + properties: { + code: { type: 'string' }, + message: { type: 'string' }, + }, + }, + }, + }, + }, }, }; } @@ -385,14 +494,21 @@ function batchSummarySchema(): JsonSchema { $schema: DRAFT, $id: id('batch-summary'), title: 'pdfnative-cli batch summary output', - description: 'JSON emitted by `pdfnative batch --summary` (minimal verdict, no per-file results).', + description: 'JSON emitted by `pdfnative batch --summary` (minimal verdict, no ' + + 'per-file results). Manifest mode additionally emits ok, command, mode, ' + + 'skipped and (under --dry-run) dryRun.', type: 'object', required: ['total', 'succeeded', 'failed'], additionalProperties: false, properties: { + ok: { type: 'boolean', description: 'Manifest mode only.' }, + command: { const: 'batch', description: 'Manifest mode only.' }, + mode: { type: 'string', enum: ['manifest'] }, total: { type: 'integer', minimum: 0 }, succeeded: { type: 'integer', minimum: 0 }, failed: { type: 'integer', minimum: 0 }, + skipped: { type: 'integer', minimum: 0 }, + dryRun: { type: 'boolean' }, }, }; } @@ -505,7 +621,8 @@ function statusSchema(): JsonSchema { title: 'pdfnative-cli agent status envelope', description: 'The success envelope written to stderr under --json by the write ' + 'commands (render, sign, merge, split, extract, annotate, fill, encrypt, ' - + 'decrypt, batch). Additional command-specific fields may be present.', + + 'decrypt, batch, metadata, ltv, doc-timestamp, compare). Additional ' + + 'command-specific fields may be present.', type: 'object', required: ['ok', 'command'], properties: { @@ -514,6 +631,170 @@ function statusSchema(): JsonSchema { dryRun: { type: 'boolean' }, output: { type: 'string' }, bytes: { type: 'integer', minimum: 0 }, + timestamp: { + type: 'object', + description: 'sign --timestamp: the TSA that produced the embedded token.', + properties: { + url: { type: 'string' }, + digest: { type: 'string', enum: ['sha256', 'sha384', 'sha512'] }, + }, + }, + diagnostics: { + type: 'array', + description: 'render: non-strict PDF/A conformance diagnostics.', + items: { + type: 'object', + properties: { + code: { type: 'string' }, + severity: { type: 'string', enum: ['warning'] }, + message: { type: 'string' }, + }, + }, + }, + }, + }; +} + +function metadataSchema(): JsonSchema { + return { + $schema: DRAFT, + $id: id('metadata'), + title: 'pdfnative-cli metadata input', + description: 'JSON accepted via --from-json by `pdfnative metadata`. All fields ' + + 'are optional, but at least one must be present. modDate is an ISO 8601 ' + + 'timestamp (defaults to now when omitted).', + type: 'object', + additionalProperties: false, + minProperties: 1, + properties: { + title: { type: 'string' }, + author: { type: 'string' }, + subject: { type: 'string' }, + keywords: { type: 'string' }, + modDate: { type: 'string' }, + }, + }; +} + +function ltvDataSchema(): JsonSchema { + return { + $schema: DRAFT, + $id: id('ltv-data'), + title: 'pdfnative-cli ltv collected validation data', + description: 'JSON emitted by `pdfnative ltv collect` and accepted by ' + + '`pdfnative ltv embed --data`: the certificates, OCSP responses and ' + + 'CRLs to archive in /DSS + /VRI (PAdES B-LT). All binary values are ' + + 'base64-encoded DER. Replayable and offline-embeddable.', + type: 'object', + required: ['version', 'certificates', 'ocspResponses', 'crls', 'vri'], + additionalProperties: false, + properties: { + version: { const: 1 }, + certificates: { type: 'array', items: { type: 'string', description: 'base64 DER certificate' } }, + ocspResponses: { type: 'array', items: { type: 'string', description: 'base64 DER OCSPResponse' } }, + crls: { type: 'array', items: { type: 'string', description: 'base64 DER CertificateList' } }, + vri: { + type: 'array', + items: { + type: 'object', + required: ['key', 'certs', 'ocsps', 'crls'], + additionalProperties: false, + properties: { + key: { + type: 'string', + description: 'Uppercase SHA-1 hex of the signature /Contents (VRI key).', + }, + certs: { type: 'array', items: { type: 'integer', minimum: 0 } }, + ocsps: { type: 'array', items: { type: 'integer', minimum: 0 } }, + crls: { type: 'array', items: { type: 'integer', minimum: 0 } }, + }, + }, + }, + }, + }; +} + +function compareSchema(): JsonSchema { + return { + $schema: DRAFT, + $id: id('compare'), + title: 'pdfnative-cli compare output', + description: 'JSON emitted by `pdfnative compare --format json`. Identical ' + + 'documents exit 0; any difference exits 1 with code E_CHECK_FAILED ' + + '(the report is printed before the error). Visual diffing is out of ' + + 'scope (no rasteriser).', + type: 'object', + required: ['equal', 'modes', 'differences'], + additionalProperties: false, + properties: { + equal: { type: 'boolean' }, + modes: { type: 'array', items: { type: 'string', enum: ['structure', 'text'] } }, + differences: { + type: 'array', + items: { + type: 'object', + required: ['kind'], + properties: { + kind: { + type: 'string', + enum: ['pageCount', 'pageSize', 'box', 'userUnit', 'metadata', + 'formFields', 'annotations', 'encryption', 'signatures', 'text'], + }, + page: { type: 'integer', minimum: 1 }, + path: { type: 'string' }, + a: { description: 'Value in the first PDF (JSON-serialisable, never raw bytes).' }, + b: { description: 'Value in the second PDF.' }, + detail: { type: 'string' }, + }, + }, + }, + }, + }; +} + +function batchManifestSchema(): JsonSchema { + return { + $schema: DRAFT, + $id: id('batch-manifest'), + title: 'pdfnative-cli batch manifest input', + description: 'Input for `pdfnative batch --manifest`. Flag values "@" ' + + 'reference the output of an EARLIER task. Relative paths resolve ' + + 'against the manifest file\'s directory. Tasks run sequentially, ' + + 'fail-fast by default. Network flags inside a manifest additionally ' + + 'require --allow-network on the command line.', + type: 'object', + required: ['version', 'tasks'], + additionalProperties: false, + properties: { + version: { const: 1 }, + tasks: { + type: 'array', + minItems: 1, + items: { + type: 'object', + required: ['id', 'command'], + additionalProperties: false, + properties: { + id: { type: 'string', pattern: '^[A-Za-z0-9_-]+$' }, + command: { + enum: ['render', 'sign', 'verify', 'inspect', 'merge', 'split', + 'extract', 'extract-text', 'fill', 'encrypt', 'decrypt', + 'annotate', 'metadata', 'doc-timestamp'], + }, + flags: { + type: 'object', + additionalProperties: { + anyOf: [ + { type: 'string' }, + { type: 'number' }, + { type: 'boolean' }, + { type: 'array', items: { type: 'string' } }, + ], + }, + }, + }, + }, + }, }, }; } @@ -554,6 +835,10 @@ const BUILDERS: Readonly JsonSchema>> = { 'verify-summary': verifySummarySchema, 'batch-summary': batchSummarySchema, 'govern-verify': governVerifySchema, + metadata: metadataSchema, + 'ltv-data': ltvDataSchema, + compare: compareSchema, + 'batch-manifest': batchManifestSchema, status: statusSchema, manifest: manifestDocument, doctor: doctorSchema, diff --git a/src/commands/sign.ts b/src/commands/sign.ts index 9bc2dd0..2c4f3db 100644 --- a/src/commands/sign.ts +++ b/src/commands/sign.ts @@ -1,169 +1,355 @@ -import { signPdfBytes, addSignaturePlaceholder, ensureCryptoReady } from '../core-bridge/index.js'; -import type { PdfSignOptions, SignatureAlgorithm } from '../core-bridge/index.js'; -import { type ParsedArgs, getStringFlag, getStringFlagAll, hasFlag } from '../utils/args.js'; -import { readFileOrStdin, writeOutput } from '../utils/io.js'; -import { CliError, ErrorCode } from '../utils/error.js'; -import { emitStatus, isDryRun } from '../utils/agent.js'; -import { - loadRsaPrivateKey, - loadEcPrivateKey, - loadCertificate, - loadPem, - loadPemChain, - parseCertificateChain, - createNativeCryptoProvider, -} from '../utils/keys.js'; - -const VALID_ALGORITHMS = new Set(['rsa-sha256', 'ecdsa-sha256']); - -function parseSigningTime(raw: string): Date { - const t = new Date(raw); - if (Number.isNaN(t.getTime())) { - throw new CliError(`Invalid --signing-time "${raw}". Expected ISO 8601 (e.g. 2026-04-28T12:00:00Z).`, 2); - } - return t; -} - -/** Validate that a flag value is a well-formed http(s) URL. */ -function assertHttpUrl(value: string, flag: string): void { - let url: URL; - try { - url = new URL(value); - } catch { - throw new CliError(`Invalid --${flag} URL "${value}".`, 2); - } - if (url.protocol !== 'http:' && url.protocol !== 'https:') { - throw new CliError(`--${flag} must be an http(s) URL, got "${url.protocol}".`, 2); - } -} - -export async function sign(args: ParsedArgs): Promise { - const inputPath = getStringFlag(args.flags, 'input', 'i'); - const outputPath = getStringFlag(args.flags, 'output', 'o'); - const keyPath = getStringFlag(args.flags, 'key'); - const certPath = getStringFlag(args.flags, 'cert'); - const algorithm = (getStringFlag(args.flags, 'algorithm') ?? 'rsa-sha256') as SignatureAlgorithm; - const reason = getStringFlag(args.flags, 'reason'); - const name = getStringFlag(args.flags, 'name'); - const location = getStringFlag(args.flags, 'location'); - const contactInfo = getStringFlag(args.flags, 'contact'); - const signingTimeRaw = getStringFlag(args.flags, 'signing-time'); - const chainPaths = getStringFlagAll(args.flags, 'cert-chain'); - const timestampUrl = getStringFlag(args.flags, 'timestamp'); - const pureCrypto = hasFlag(args.flags, 'pure-crypto'); - const dryRun = hasFlag(args.flags, 'dry-run') || isDryRun(); - - if (!VALID_ALGORITHMS.has(algorithm)) { - throw new CliError( - `Invalid --algorithm "${algorithm}". Valid: rsa-sha256, ecdsa-sha256.`, - 2, - ); - } - - // Sign-side RFC 3161 timestamping (PAdES-T) requires timestamp-token - // embedding inside the CMS SignedData — PDF-writing logic that belongs in - // pdfnative and is not yet exposed (≤ 1.2.0). We surface the flag so the - // CLI contract is stable, but fail clearly rather than silently ignoring - // it. Verify-side timestamp validation IS supported (`pdfnative verify`). - if (timestampUrl !== undefined) { - assertHttpUrl(timestampUrl, 'timestamp'); - throw new CliError( - 'Sign-side RFC 3161 timestamping (PAdES-T) is not yet available: embedding a ' - + 'timestamp token at signing time requires upstream support in pdfnative ' - + '(tracked at https://github.com/pdfnative/pdfnative/issues). ' - + 'Timestamp VALIDATION is already supported — run `pdfnative verify` on a ' - + 'timestamped PDF.', - 2, - ErrorCode.UNSUPPORTED, - ); - } - - // Validate scalar flags up-front so usage errors (exit 2) are reported - // before any I/O or expensive PEM parsing. - const signingTime = signingTimeRaw !== undefined ? parseSigningTime(signingTimeRaw) : undefined; - - // Pre-flight: assert credentials are reachable BEFORE doing any expensive parsing. - // This guarantees a usage error (exit 2) is reported when a flag/env var is missing, - // even if a partial set of credentials would parse successfully. - if (process.env['PDFNATIVE_SIGN_KEY'] === undefined && keyPath === undefined) { - throw new CliError('Missing private key. Provide $PDFNATIVE_SIGN_KEY (env) or --key .', 2); - } - if (process.env['PDFNATIVE_SIGN_CERT'] === undefined && certPath === undefined) { - throw new CliError('Missing certificate. Provide $PDFNATIVE_SIGN_CERT (env) or --cert .', 2); - } - - // Async crypto bootstrap MUST run before any RSA/ECDSA key parsing. - // pdfnative throws "ASN.1 module must be imported" otherwise. - await ensureCryptoReady(); - - const pdfBuf = await readFileOrStdin(inputPath); - let pdfBytes: Uint8Array = new Uint8Array(pdfBuf); - - // Load credentials. Env vars beat file flags (OWASP best practice). - const signerCert = await loadCertificate('PDFNATIVE_SIGN_CERT', certPath, 'cert'); - - // Optional intermediate-CA chain - const chainPemBlocks = await loadPemChain('PDFNATIVE_SIGN_CHAIN', chainPaths); - const certChain = chainPemBlocks.length > 0 ? parseCertificateChain(chainPemBlocks) : undefined; - - const options: { -readonly [K in keyof PdfSignOptions]: PdfSignOptions[K] } = { - signerCert, - algorithm, - }; - // Signing engine. By default the CLI routes CMS signing through a native, - // constant-time (side-channel-resistant) OpenSSL signer via node:crypto. - // `--pure-crypto` forces pdfnative's pure-JS RSA/ECDSA math (useful for - // reproducibility or environments without node:crypto). - if (pureCrypto) { - if (algorithm === 'ecdsa-sha256') { - options.ecKey = await loadEcPrivateKey('PDFNATIVE_SIGN_KEY', keyPath, 'key'); - } else { - options.rsaKey = await loadRsaPrivateKey('PDFNATIVE_SIGN_KEY', keyPath, 'key'); - } - } else { - const keyPem = await loadPem('PDFNATIVE_SIGN_KEY', keyPath, 'private key', 'key'); - options.provider = createNativeCryptoProvider(keyPem); - } - if (certChain !== undefined) options.certChain = certChain; - if (reason !== undefined) options.reason = reason; - if (name !== undefined) options.name = name; - if (location !== undefined) options.location = location; - if (contactInfo !== undefined) options.contactInfo = contactInfo; - if (signingTime !== undefined) options.signingTime = signingTime; - - // Auto-inject a signature placeholder when the input PDF doesn't already - // carry one (the common case for `pdfnative render`-produced PDFs, which - // ship no AcroForm). pdfnative's addSignaturePlaceholder is idempotent: - // a PDF that already carries a /FT /Sig widget is returned unchanged. - try { - pdfBytes = addSignaturePlaceholder(pdfBytes); - } catch (e) { - if (e instanceof CliError) throw e; - throw new CliError('Failed to prepare PDF for signing.', 1, ErrorCode.SIGN); - } - - // Dry-run: credentials parsed, PDF read and placeholder-prepared. Stop - // before producing (or writing) a signature. No key material is touched - // beyond the validation already performed above. - if (dryRun) { - emitStatus({ command: 'sign', dryRun: true, algorithm, output: outputPath ?? '-' }); - return; - } - - let signedBytes: Uint8Array; - try { - signedBytes = signPdfBytes(pdfBytes, options); - } catch (e) { - // Never include the underlying message — it may reference key bytes or hashes. - if (e instanceof CliError) throw e; - throw new CliError('Failed to sign PDF.', 1, ErrorCode.SIGN); - } - await writeOutput(signedBytes, outputPath); - emitStatus({ - command: 'sign', - dryRun: false, - algorithm, - output: outputPath ?? '-', - bytes: signedBytes.length, - }); -} +import { + signPdfBytes, + signPdfBytesWithTimestamp, + addSignaturePlaceholder, + estimateContentsSize, + getTimestampProvider, + ensureCryptoReady, +} from '../core-bridge/index.js'; +import type { + PdfSignOptions, + PdfSignTimestampOptions, + SignatureAlgorithm, + AddSignaturePlaceholderOptions, + CmsDigestAlgorithm, + CmsProfile, + X509Certificate, +} from '../core-bridge/index.js'; +import { randomBytes } from 'node:crypto'; +import { type ParsedArgs, getStringFlag, getStringFlagAll, hasFlag } from '../utils/args.js'; +import { readFileOrStdin, writeOutput } from '../utils/io.js'; +import { CliError, ErrorCode } from '../utils/error.js'; +import { emitStatus, isDryRun } from '../utils/agent.js'; +import { createTsaProvider } from '../utils/tsa.js'; +import { + loadRsaPrivateKey, + loadEcPrivateKey, + loadCertificate, + loadPem, + loadPemChain, + parseCertificateChain, + createNativeCryptoProvider, +} from '../utils/keys.js'; + +const VALID_ALGORITHMS = new Set(['rsa-sha256', 'ecdsa-sha256']); + +function parseSigningTime(raw: string): Date { + const t = new Date(raw); + if (Number.isNaN(t.getTime())) { + throw new CliError(`Invalid --signing-time "${raw}". Expected ISO 8601 (e.g. 2026-04-28T12:00:00Z).`, 2); + } + return t; +} + +/** Validate that a flag value is a well-formed http(s) URL. */ +function assertHttpUrl(value: string, flag: string): void { + let url: URL; + try { + url = new URL(value); + } catch { + throw new CliError(`Invalid --${flag} URL "${value}".`, 2); + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new CliError(`--${flag} must be an http(s) URL, got "${url.protocol}".`, 2); + } +} + +/** Validate a CMS digest flag value (sha256 | sha384 | sha512). */ +function parseCmsDigest(raw: string, flag: string): CmsDigestAlgorithm { + if (raw === 'sha256' || raw === 'sha384' || raw === 'sha512') return raw; + throw new CliError(`Invalid --${flag} "${raw}". Valid: sha256, sha384, sha512.`, 2); +} + +/** Parse an RFC 3161 nonce from a hex string (optional 0x prefix). */ +function parseNonceHex(raw: string): bigint { + const hex = raw.startsWith('0x') || raw.startsWith('0X') ? raw.slice(2) : raw; + if (!/^[0-9a-fA-F]{1,64}$/.test(hex)) { + throw new CliError( + `Invalid --timestamp-nonce "${raw}". Expected a hex string of at most 32 bytes (64 hex chars).`, + 2, + ); + } + return BigInt('0x' + hex); +} + +/** Parse `--signature-rect "x1,y1,x2,y2"` into a 4-number tuple. */ +function parseSignatureRect(raw: string): readonly [number, number, number, number] { + const parts = raw.split(',').map((p) => p.trim()); + const nums = parts.map((p) => (p.length === 0 ? Number.NaN : Number(p))); + const [x1, y1, x2, y2] = nums; + if (nums.length !== 4 || x1 === undefined || y1 === undefined || x2 === undefined || y2 === undefined + || nums.some((n) => !Number.isFinite(n))) { + throw new CliError( + `Invalid --signature-rect "${raw}". Expected four comma-separated numbers "x1,y1,x2,y2".`, + 2, + ); + } + return [x1, y1, x2, y2]; +} + +/** Parse a strictly positive integer flag value. */ +function parsePositiveInt(raw: string, flag: string): number { + const n = Number(raw); + if (!Number.isInteger(n) || n < 1) { + throw new CliError(`Invalid --${flag} "${raw}". Expected a positive integer.`, 2); + } + return n; +} + +export async function sign(args: ParsedArgs): Promise { + const inputPath = getStringFlag(args.flags, 'input', 'i'); + const outputPath = getStringFlag(args.flags, 'output', 'o'); + const keyPath = getStringFlag(args.flags, 'key'); + const certPath = getStringFlag(args.flags, 'cert'); + const algorithm = (getStringFlag(args.flags, 'algorithm') ?? 'rsa-sha256') as SignatureAlgorithm; + const reason = getStringFlag(args.flags, 'reason'); + const name = getStringFlag(args.flags, 'name'); + const location = getStringFlag(args.flags, 'location'); + const contactInfo = getStringFlag(args.flags, 'contact'); + const signingTimeRaw = getStringFlag(args.flags, 'signing-time'); + const chainPaths = getStringFlagAll(args.flags, 'cert-chain'); + const timestampUrl = getStringFlag(args.flags, 'timestamp'); + const pureCrypto = hasFlag(args.flags, 'pure-crypto'); + const dryRun = hasFlag(args.flags, 'dry-run') || isDryRun(); + + if (!VALID_ALGORITHMS.has(algorithm)) { + throw new CliError( + `Invalid --algorithm "${algorithm}". Valid: rsa-sha256, ecdsa-sha256.`, + 2, + ); + } + + // ── Validate scalar flags up-front so usage errors (exit 2) are + // reported before any I/O or expensive PEM parsing. ───────────────── + + // Sign-side RFC 3161 timestamping (PAdES B-T, pdfnative ≥ 1.7.0). + if (timestampUrl !== undefined) { + assertHttpUrl(timestampUrl, 'timestamp'); + } + const timestampDigestRaw = getStringFlag(args.flags, 'timestamp-digest'); + const timestampDigest: CmsDigestAlgorithm = timestampDigestRaw !== undefined + ? parseCmsDigest(timestampDigestRaw, 'timestamp-digest') + : 'sha256'; + const timestampNonceRaw = getStringFlag(args.flags, 'timestamp-nonce'); + const explicitNonce = timestampNonceRaw !== undefined ? parseNonceHex(timestampNonceRaw) : undefined; + + // CMS message digest. pdfnative requires digestAlgorithm to match the + // algorithm's implied digest, so sha384/sha512 promote rsa-sha256 to the + // corresponding rsa-sha384/rsa-sha512 SignatureAlgorithm. ECDSA is + // P-256/SHA-256 only. + const digestRaw = getStringFlag(args.flags, 'digest'); + const digest = digestRaw !== undefined ? parseCmsDigest(digestRaw, 'digest') : undefined; + let effectiveAlgorithm: SignatureAlgorithm = algorithm; + if (digest !== undefined && digest !== 'sha256') { + if (algorithm === 'ecdsa-sha256') { + throw new CliError( + `--digest ${digest} is not supported with ecdsa-sha256 (P-256 signatures are SHA-256 only).`, + 2, + ); + } + effectiveAlgorithm = digest === 'sha384' ? 'rsa-sha384' : 'rsa-sha512'; + } + + // CMS profile: classic PKCS#7 (default) or PAdES baseline (ETSI EN 319 142-1). + const profileRaw = getStringFlag(args.flags, 'profile'); + let profile: CmsProfile | undefined; + if (profileRaw !== undefined) { + if (profileRaw !== 'pkcs7' && profileRaw !== 'pades') { + throw new CliError(`Invalid --profile "${profileRaw}". Valid: pkcs7, pades.`, 2); + } + profile = profileRaw; + } + + // Multi-signature / visible-signature placeholder flags (pdfnative 1.7.0). + const allowMultiple = hasFlag(args.flags, 'allow-multiple'); + const fieldName = getStringFlag(args.flags, 'field-name'); + const signatureRectRaw = getStringFlag(args.flags, 'signature-rect'); + const signatureRect = signatureRectRaw !== undefined ? parseSignatureRect(signatureRectRaw) : undefined; + const signaturePageRaw = getStringFlag(args.flags, 'signature-page'); + // CLI pages are 1-based; AddSignaturePlaceholderOptions.pageIndex is 0-based. + const pageIndex = signaturePageRaw !== undefined + ? parsePositiveInt(signaturePageRaw, 'signature-page') - 1 + : undefined; + const placeholderBytesRaw = getStringFlag(args.flags, 'placeholder-bytes'); + const explicitPlaceholderBytes = placeholderBytesRaw !== undefined + ? parsePositiveInt(placeholderBytesRaw, 'placeholder-bytes') + : undefined; + + const signingTime = signingTimeRaw !== undefined ? parseSigningTime(signingTimeRaw) : undefined; + + // Pre-flight: assert credentials are reachable BEFORE doing any expensive parsing. + // This guarantees a usage error (exit 2) is reported when a flag/env var is missing, + // even if a partial set of credentials would parse successfully. + if (process.env['PDFNATIVE_SIGN_KEY'] === undefined && keyPath === undefined) { + throw new CliError('Missing private key. Provide $PDFNATIVE_SIGN_KEY (env) or --key .', 2); + } + if (process.env['PDFNATIVE_SIGN_CERT'] === undefined && certPath === undefined) { + throw new CliError('Missing certificate. Provide $PDFNATIVE_SIGN_CERT (env) or --cert .', 2); + } + + // Async crypto bootstrap MUST run before any RSA/ECDSA key parsing. + // pdfnative throws "ASN.1 module must be imported" otherwise. + await ensureCryptoReady(); + + const pdfBuf = await readFileOrStdin(inputPath); + let pdfBytes: Uint8Array = new Uint8Array(pdfBuf); + + // Load credentials. Env vars beat file flags (OWASP best practice). + const signerCert = await loadCertificate('PDFNATIVE_SIGN_CERT', certPath, 'cert'); + + // Optional intermediate-CA chain + const chainPemBlocks = await loadPemChain('PDFNATIVE_SIGN_CHAIN', chainPaths); + const certChain = chainPemBlocks.length > 0 ? parseCertificateChain(chainPemBlocks) : undefined; + + const options: { -readonly [K in keyof PdfSignOptions]: PdfSignOptions[K] } = { + signerCert, + algorithm: effectiveAlgorithm, + }; + // Signing engine. By default the CLI routes CMS signing through a native, + // constant-time (side-channel-resistant) OpenSSL signer via node:crypto. + // `--pure-crypto` forces pdfnative's pure-JS RSA/ECDSA math (useful for + // reproducibility or environments without node:crypto). + if (pureCrypto) { + if (algorithm === 'ecdsa-sha256') { + options.ecKey = await loadEcPrivateKey('PDFNATIVE_SIGN_KEY', keyPath, 'key'); + } else { + options.rsaKey = await loadRsaPrivateKey('PDFNATIVE_SIGN_KEY', keyPath, 'key'); + } + } else { + const keyPem = await loadPem('PDFNATIVE_SIGN_KEY', keyPath, 'private key', 'key'); + options.provider = createNativeCryptoProvider(keyPem); + } + if (certChain !== undefined) options.certChain = certChain; + if (reason !== undefined) options.reason = reason; + if (name !== undefined) options.name = name; + if (location !== undefined) options.location = location; + if (contactInfo !== undefined) options.contactInfo = contactInfo; + if (signingTime !== undefined) options.signingTime = signingTime; + if (digest !== undefined) options.digestAlgorithm = digest; + if (profile !== undefined) options.profile = profile; + if (fieldName !== undefined) options.fieldName = fieldName; + // PAdES pairing (ETSI EN 319 142-1): the CMS `pades` profile goes with the + // /SubFilter ETSI.CAdES.detached declared in the /Sig dictionary. + if (profile === 'pades') options.subFilter = 'ETSI.CAdES.detached'; + + // ── Placeholder options (only passed when a 1.7.0 feature is used, so + // the legacy no-flag path stays byte-identical). ───────────────────── + const placeholderOptions: { + -readonly [K in keyof AddSignaturePlaceholderOptions]: AddSignaturePlaceholderOptions[K]; + } = {}; + let usePlaceholderOptions = false; + if (allowMultiple) { + placeholderOptions.allowMultiple = true; + usePlaceholderOptions = true; + } + if (fieldName !== undefined) { + placeholderOptions.fieldName = fieldName; + usePlaceholderOptions = true; + } + if (pageIndex !== undefined) { + placeholderOptions.pageIndex = pageIndex; + usePlaceholderOptions = true; + } + if (signatureRect !== undefined) { + placeholderOptions.rect = signatureRect; + usePlaceholderOptions = true; + } + if (profile === 'pades') { + placeholderOptions.metadata = { subFilter: 'ETSI.CAdES.detached' }; + usePlaceholderOptions = true; + } + // /Contents sizing: an explicit --placeholder-bytes wins; otherwise a + // timestamped signature reserves room for the RFC 3161 token (the TSA's + // certificate chain rides inside the CMS unsigned attributes). + if (explicitPlaceholderBytes !== undefined) { + placeholderOptions.placeholderBytes = explicitPlaceholderBytes; + usePlaceholderOptions = true; + } else if (timestampUrl !== undefined) { + const certSizes = [signerCert, ...(certChain ?? [])].map((c: X509Certificate) => c.raw.length); + placeholderOptions.placeholderBytes = estimateContentsSize(certSizes, effectiveAlgorithm, { timestamp: true }); + usePlaceholderOptions = true; + } + + // Auto-inject a signature placeholder when the input PDF doesn't already + // carry one (the common case for `pdfnative render`-produced PDFs, which + // ship no AcroForm). pdfnative's addSignaturePlaceholder is idempotent: + // a PDF that already carries a /FT /Sig widget is returned unchanged + // (unless --allow-multiple opts into the multi-signature flow). + try { + pdfBytes = usePlaceholderOptions + ? addSignaturePlaceholder(pdfBytes, placeholderOptions) + : addSignaturePlaceholder(pdfBytes); + } catch (e) { + if (e instanceof CliError) throw e; + throw new CliError('Failed to prepare PDF for signing.', 1, ErrorCode.SIGN); + } + + // Dry-run: credentials parsed, PDF read and placeholder-prepared. Stop + // before producing (or writing) a signature — and before ANY network + // byte moves: with --timestamp the TSA is never contacted on a dry-run + // (the URL is only validated above). No key material is touched beyond + // the validation already performed. + if (dryRun) { + const envelope: Record = { + command: 'sign', + dryRun: true, + algorithm, + output: outputPath ?? '-', + }; + if (timestampUrl !== undefined) { + envelope['timestamp'] = { url: timestampUrl, digest: timestampDigest }; + } + emitStatus(envelope); + return; + } + + let signedBytes: Uint8Array; + if (timestampUrl !== undefined) { + // PAdES B-T path: sign, then obtain an RFC 3161 timestamp over the CMS + // signature value and embed it as an unsigned attribute. A globally + // injected provider (setTimestampProvider — the tests' seam) beats the + // CLI's SSRF-guarded HTTP transport. NEVER falls back to an + // untimestamped signature: any TSA failure aborts the command. + const timestampProvider = getTimestampProvider() ?? createTsaProvider(timestampUrl); + const timestampNonce = explicitNonce ?? BigInt('0x' + randomBytes(8).toString('hex')); + const tsOptions: PdfSignTimestampOptions = { + ...options, + timestampProvider, + timestampDigestAlgorithm: timestampDigest, + timestampNonce, + }; + try { + signedBytes = await signPdfBytesWithTimestamp(pdfBytes, tsOptions); + } catch (e) { + // CliError = transport failure from the TSA provider (E_NETWORK). + if (e instanceof CliError) throw e; + // Anything else is a rejected/malformed TSA response (or a signing + // failure). Generic message — never echo TSA bytes or key material. + throw new CliError( + 'Failed to produce a timestamped signature: the TSA response was rejected or could not be parsed.', + 1, + ErrorCode.PARSE, + ); + } + } else { + try { + signedBytes = signPdfBytes(pdfBytes, options); + } catch (e) { + // Never include the underlying message — it may reference key bytes or hashes. + if (e instanceof CliError) throw e; + throw new CliError('Failed to sign PDF.', 1, ErrorCode.SIGN); + } + } + await writeOutput(signedBytes, outputPath); + const envelope: Record = { + command: 'sign', + dryRun: false, + algorithm, + output: outputPath ?? '-', + bytes: signedBytes.length, + }; + if (timestampUrl !== undefined) { + envelope['timestamp'] = { url: timestampUrl, digest: timestampDigest }; + } + emitStatus(envelope); +} diff --git a/src/commands/verify.ts b/src/commands/verify.ts index b5d013c..08bb65f 100644 --- a/src/commands/verify.ts +++ b/src/commands/verify.ts @@ -3,6 +3,7 @@ import { openPdf, ensureCryptoReady, parseCertificate, + listSignatures, isRef, isName, isDict, @@ -13,6 +14,7 @@ import type { PdfReader, PdfDict, PdfValue, + PdfSignatureInfo, X509Certificate, X509Name, } from '../core-bridge/index.js'; @@ -23,9 +25,16 @@ import { isJsonMode } from '../utils/agent.js'; import { selectFields, serializeJson, parseFieldList } from '../utils/projection.js'; import { walkAbs, sliceNode, sliceContent, type AbsNode } from '../utils/asn1-walk.js'; import { loadPemChain, parseCertificateChain } from '../utils/keys.js'; -import { verifyCmsSignatureValue, extractUnsignedAttrs, extractSignerSignatureValue } from '../utils/cms-verify.js'; +import { + verifyCmsSignatureValue, + extractUnsignedAttrs, + extractSignerSignatureValue, + extractSignerDigestAlgorithm, + type CmsSignatureAlgorithm, + type CmsDigestName, +} from '../utils/cms-verify.js'; import { buildChain, isTrustedRoot } from '../utils/cert-chain.js'; -import { verifyTimestamp } from '../utils/timestamp-verify.js'; +import { verifyTimestamp, verifyDocTimestamp } from '../utils/timestamp-verify.js'; import { checkRevocation, type RevocationMode, @@ -47,6 +56,8 @@ import { * ✔ RFC 3161 signature-time-stamp-token validation (PAdES-T) * ✔ OCSP (RFC 6960) + CRL (RFC 5280) revocation — embedded DSS (offline, * default) and opt-in online fetching (--revocation online, SSRF-guarded) + * ✔ RSA-SHA-384/512 signatures + /DocTimeStamp (PAdES B-LTA) revision + * validation via listSignatures pairing (v1.4.0) * * Out of scope: * ✘ Sign-side LTV (embedding timestamps / DSS) — tracked upstream in pdfnative @@ -56,6 +67,8 @@ interface SignatureReport { readonly index: number; readonly fieldName: string | null; readonly subFilter: string | null; + /** True for /DocTimeStamp entries (ETSI.RFC3161 document timestamps). */ + readonly isDocTimestamp: boolean; readonly signerSubject: string | null; readonly signerIssuer: string | null; readonly signingTime: string | null; @@ -66,7 +79,7 @@ interface SignatureReport { readonly chainValid: boolean; readonly trustedRoot: boolean; readonly signatureValid: boolean; - readonly signatureAlgorithm: 'rsa-sha256' | 'ecdsa-sha256' | null; + readonly signatureAlgorithm: CmsSignatureAlgorithm | null; readonly timestampPresent: boolean; readonly timestampValid: boolean; readonly timestampTime: string | null; @@ -111,9 +124,10 @@ function bytesToHex(bytes: Uint8Array): string { function digestByteRange( pdfBytes: Uint8Array, byteRange: readonly [number, number, number, number], + algorithm: CmsDigestName = 'sha256', ): string { const [a, b, c, d] = byteRange; - const hash = createHash('sha256'); + const hash = createHash(algorithm); hash.update(pdfBytes.subarray(a, a + b)); hash.update(pdfBytes.subarray(c, c + d)); return hash.digest('hex'); @@ -407,10 +421,44 @@ export async function verify(args: ParsedArgs): Promise { const fields = findSignatureFields(reader); const reports: SignatureReport[] = []; + // Signature inventory from pdfnative — the authority on /DocTimeStamp + // detection (/Type /DocTimeStamp) and a fieldName fallback. Entries are + // paired with the AcroForm walk above by /ByteRange (or ordinal position). + let sigInfos: readonly PdfSignatureInfo[] = []; + try { + sigInfos = listSignatures(pdfBytes); + } catch { + sigInfos = []; + } + const usedInfos = new Set(); + const pairSignatureInfo = ( + byteRange: readonly [number, number, number, number] | null, + ordinal: number, + ): PdfSignatureInfo | null => { + if (byteRange !== null) { + for (let i = 0; i < sigInfos.length; i++) { + const info = sigInfos[i] as PdfSignatureInfo; + if (usedInfos.has(i)) continue; + if (info.byteRange.length === 4 && info.byteRange.every((v, j) => v === byteRange[j])) { + usedInfos.add(i); + return info; + } + } + } + if (ordinal < sigInfos.length && !usedInfos.has(ordinal)) { + usedInfos.add(ordinal); + return sigInfos[ordinal] as PdfSignatureInfo; + } + return null; + }; + for (let idx = 0; idx < fields.length; idx++) { const field = fields[idx] as (typeof fields)[number]; const notes: string[] = []; const sig = parseSignatureDict(field.sigDict); + const info = pairSignatureInfo(sig.byteRange, idx); + const isDocTimestamp = info?.isDocTimestamp ?? sig.subFilter === 'ETSI.RFC3161'; + const fieldName = field.fieldName ?? info?.fieldName ?? null; let digest: string | null = null; let integrity = false; let signerSubject: string | null = null; @@ -418,7 +466,7 @@ export async function verify(args: ParsedArgs): Promise { let chainValid = false; let trustedRoot = false; let signatureValid = false; - let signatureAlgorithm: 'rsa-sha256' | 'ecdsa-sha256' | null = null; + let signatureAlgorithm: CmsSignatureAlgorithm | null = null; let timestampPresent = false; let timestampValid = false; let timestampTime: string | null = null; @@ -429,13 +477,43 @@ export async function verify(args: ParsedArgs): Promise { let revocationMethod: 'ocsp' | 'crl' | null = null; let revocationRevokedAt: string | null = null; - if (sig.byteRange !== null) { - digest = digestByteRange(pdfBytes, sig.byteRange); - } else { + if (sig.byteRange === null) { notes.push('missing /ByteRange'); } - if (sig.contents !== null) { + if (isDocTimestamp) { + // /DocTimeStamp revision (PAdES B-LTA): /Contents is a bare RFC + // 3161 TimeStampToken over the /ByteRange, not a document CMS. + if (sig.contents === null) { + notes.push('missing /Contents'); + } else if (sig.byteRange !== null) { + const dts = verifyDocTimestamp(sig.contents, pdfBytes, sig.byteRange, trustRoots); + digest = dts.imprintHex; + integrity = dts.imprintValid; + signatureValid = dts.signatureValid; + signatureAlgorithm = dts.algorithm; + timestampPresent = true; + timestampValid = dts.valid; + timestampTime = dts.genTime; + tsaSubject = dts.tsaSubject; + chainValid = dts.chainValid; + trustedRoot = dts.trusted; + if (dts.valid) { + notes.push( + `document timestamp valid (genTime ${dts.genTime ?? 'unknown'}` + + `${dts.trusted ? ', TSA trusted' : ', TSA untrusted'})`, + ); + } else { + notes.push(`document timestamp invalid${dts.note !== null ? `: ${dts.note}` : ''}`); + } + } + } else if (sig.contents !== null) { + // messageDigest is computed with the SignerInfo digestAlgorithm + // (SHA-256/384/512) — hash the /ByteRange with the same digest. + if (sig.byteRange !== null) { + const digestName = extractSignerDigestAlgorithm(sig.contents) ?? 'sha256'; + digest = digestByteRange(pdfBytes, sig.byteRange, digestName); + } try { const root = walkAbs(sig.contents); const certDers = extractCertsFromCms(sig.contents, root); @@ -524,13 +602,17 @@ export async function verify(args: ParsedArgs): Promise { notes.push('failed to parse CMS (malformed or unsupported structure)'); } } else { + if (sig.byteRange !== null) { + digest = digestByteRange(pdfBytes, sig.byteRange); + } notes.push('missing /Contents'); } reports.push({ index: idx, - fieldName: field.fieldName, + fieldName, subFilter: sig.subFilter, + isDocTimestamp, signerSubject, signerIssuer, signingTime: sig.signingTime, @@ -564,11 +646,17 @@ export async function verify(args: ParsedArgs): Promise { return true; }; - const allValid = - reports.length > 0 - && reports.every( - (r) => r.integrity && r.chainValid && r.trustedRoot && r.signatureValid && revocationOk(r), - ); + // /DocTimeStamp entries gate on the RFC 3161 checks only: imprint match + // (integrity) + TSA token signature. TSA chain/trust and revocation are + // reported but — as with PAdES-T signature timestamps — never block, so + // a valid B-LTA document passes --strict while a tampered byte range + // (imprint mismatch) fails it. + const reportOk = (r: SignatureReport): boolean => + r.isDocTimestamp + ? r.integrity && r.signatureValid && r.timestampValid + : r.integrity && r.chainValid && r.trustedRoot && r.signatureValid && revocationOk(r); + + const allValid = reports.length > 0 && reports.every(reportOk); const result: VerifyResult = { signatures: reports, allValid }; @@ -592,7 +680,8 @@ export async function verify(args: ParsedArgs): Promise { process.stdout.write(`Signatures: ${reports.length}\n`); for (const r of reports) { process.stdout.write( - `\n[${r.index}] field=${r.fieldName ?? '—'} subFilter=${r.subFilter ?? '—'}\n` + `\n[${r.index}] field=${r.fieldName ?? '—'} subFilter=${r.subFilter ?? '—'}` + + `${r.isDocTimestamp ? ' docTimestamp=yes' : ''}\n` + ` signer: ${r.signerSubject ?? '—'}\n` + ` issuer: ${r.signerIssuer ?? '—'}\n` + ` signed at: ${r.signingTime ?? '—'}\n` diff --git a/src/core-bridge/index.ts b/src/core-bridge/index.ts index bd82b0e..710c0cf 100644 --- a/src/core-bridge/index.ts +++ b/src/core-bridge/index.ts @@ -156,3 +156,57 @@ export type { export type { PdfReader, PdfValue, PdfName, PdfRef, PdfStream } from 'pdfnative'; export type { ParsedDict as PdfDict, ParsedArray as PdfArray } from 'pdfnative'; export type { PdfUAValidationResult } from 'pdfnative'; + +// ── PAdES B-T — RFC 3161 timestamped signing (pdfnative 1.7.0) ─────── +// The engine never opens a socket: the CLI injects a TimestampProvider +// built on the SSRF-guarded fetch (src/utils/tsa.ts). +export { signPdfBytesWithTimestamp, estimateContentsSize } from 'pdfnative'; +export { setTimestampProvider, getTimestampProvider } from 'pdfnative'; +export type { PdfSignTimestampOptions, TimestampProvider } from 'pdfnative'; + +// ── PAdES B-LT — /DSS + /VRI long-term validation (pdfnative 1.7.0) ── +// collectValidationInfo needs a RevocationProvider (network, injected by +// the CLI); embedValidationInfo is synchronous and offline by design. +export { collectValidationInfo, embedValidationInfo, addValidationInfo, vriKeyForContents } from 'pdfnative'; +export { setRevocationProvider, getRevocationProvider } from 'pdfnative'; +export type { LtvData, CollectLtvOptions, RevocationProvider } from 'pdfnative'; + +// ── PAdES B-LTA — document timestamps (pdfnative 1.7.0) ────────────── +export { addDocumentTimestamp } from 'pdfnative'; +export type { AddDocumentTimestampOptions } from 'pdfnative'; + +// ── Signature inventory + multi-signature options (pdfnative 1.7.0) ── +export { listSignatures } from 'pdfnative'; +export type { PdfSignatureInfo, CmsDigestAlgorithm, CmsProfile, RsaDigest } from 'pdfnative'; + +// ── RFC 3161 token parsing — /DocTimeStamp validation in verify ────── +export { buildTimestampRequest, parseTimestampResponse, parseTimestampToken, verifyTimestampImprint } from 'pdfnative'; +export type { TimestampResponse, TstInfo } from 'pdfnative'; + +// ── Incremental metadata update (pdfnative 1.7.0) ──────────────────── +export type { PdfMetadataUpdate } from 'pdfnative'; + +// ── Print production + PDF/A diagnostics + viewer prefs (1.7.0) ────── +export type { + PrintOptions, + PrinterMarksOptions, + PageBox, + CustomOutputIntent, + ViewerPreferences, + DocumentMetadata, + PdfDiagnostic, + PdfDiagnosticCode, + PdfDiagnosticHandler, +} from 'pdfnative'; + +// ── Document image blocks — CLI resolves src / dataBase64 to bytes ─── +export type { ImageBlock, DocumentBlock } from 'pdfnative'; + +// ── Untrusted-input inflate cap (anti zip-bomb, --max-inflate-size) ── +export { setMaxInflateOutputSize, getMaxInflateOutputSize, DEFAULT_MAX_INFLATE_OUTPUT } from 'pdfnative'; + +// ── DER / hash / RSA primitives — consumed by tests/helpers/mock-pki +// (offline TSA + OCSP/CRL responders) and the LTV plumbing. Kept in the +// bridge so tests never import 'pdfnative' directly. +export { derSequence, derSetOf, derOid, derInteger, derBitString, derOctetString, derGeneralizedTime } from 'pdfnative'; +export { sha1, rsaSignHash } from 'pdfnative'; diff --git a/src/index.ts b/src/index.ts index ab7b991..37eb6e7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,12 +13,13 @@ pdfnative-cli — Official CLI for pdfnative Usage: pdfnative [options] -Commands (17): +Commands (21): Create & edit render Render a JSON document definition to PDF fill Fill / flatten / export an AcroForm PDF annotate Attach markup annotations to a PDF + metadata Update PDF /Info + XMP metadata (incremental — keeps signatures) Page tree merge Concatenate multiple PDFs into one @@ -26,17 +27,20 @@ Commands (17): extract Extract selected pages into a new PDF Security - sign Apply a digital signature to a PDF + sign Apply a digital signature to a PDF (RFC 3161 timestamp, multi-sig) verify Verify embedded PDF signatures + ltv PAdES B-LT: collect/embed OCSP+CRL validation data (/DSS) + doc-timestamp PAdES B-LTA: append an RFC 3161 document timestamp encrypt Re-secure a PDF with AES-128/256 encryption decrypt Remove encryption from a PDF (with --password) Read & extract inspect Analyse a PDF (metadata, conformance, form fields, encryption) extract-text Extract reading-order text (text | json | ndjson) + compare Diff two PDFs by text and structure (CI-friendly exit codes) Automation & meta - batch Render every JSON file in a directory to PDF (parallel) + batch Render a directory or run a multi-command manifest pipeline doctor Environment / capability preflight (text or --json) schema Print a JSON Schema / capability manifest for agents completion Emit a shell completion script (bash|zsh|fish|powershell) @@ -55,7 +59,11 @@ Global options (any command): (data stays on stdout). Errors carry a stable code. --dry-run Validate inputs and exit without writing output (render, sign, batch, merge, split, extract, annotate, fill, - encrypt, decrypt). + encrypt, decrypt, metadata, ltv, doc-timestamp). Never + performs network I/O, even when a network flag is present. + --max-inflate-size + Cap the decompressed size of any single PDF stream while + parsing untrusted input (anti zip-bomb; default 100 MiB). For autonomous/agent usage see AGENTS.md. Run \`pdfnative --help\` for per-command options. @@ -82,10 +90,17 @@ I/O: materialises. Same constraints as --stream (no TOC, no {pages}); byte-identical output. Mutually exclusive with the other --stream* flags. + --chunk-size Chunk size in bytes for --stream / --stream-true (default + 65536). Not applicable to --stream-page-by-page. --watch Re-render on input file change (requires --input and a file --output; logs to stderr; debounce 200 ms). --template Path to JSON template file. Stdin / --input is deep-merged on top (caller wins; arrays replace). + --outline auto (bookmarks from headings) or a JSON outline file + --debug-layout Draw the layout-debug overlay (boxes/baselines) in the PDF + --inspect-layout + Emit the block-placement report as JSON on stdout instead + of rendering the PDF Variant: --variant document (default) or table @@ -103,21 +118,34 @@ Layout (flags override values from --layout file): --page-size Named (a4|letter|legal|a3|tabloid|a5) or WxH in points --margin Uniform N or "top,right,bottom,left" in points --tagged none|pdfa1b|pdfa2b|pdfa2u|pdfa3b (PDF/A flag) + --strict Escalate PDF/A conformance diagnostics (PDFA_NO_FONT_ENTRIES, + PDFA_UNEMBEDDED_FORM_FONT, PDFA_DEVICE_CMYK_IMAGE) into an + error BEFORE any output byte (exit 1, E_CHECK_FAILED). + Without it, diagnostics are stderr warnings (and a + diagnostics[] array in the --json envelope). --conformance DEPRECATED — alias for --tagged pdfa{1b|2b|3b} --compress Enable Flate compression (initialises Node compression) --max-blocks Max document blocks before pdfnative aborts (default 100000) --lang Comma-separated language packs (e.g. th,ja,ar,te,si,km) --font Register a bundled font shortcut (repeatable). The name doubles as the --lang code. Allowed: latin, emoji, - color-emoji, and the 22 script codes ar, hy, bn, ru, hi, am, - ka, el, he, ja, km, ko, my, pl, zh, si, ta, te, th, bo, tr, - vi. + color-emoji, math, and the 22 script codes ar, hy, bn, ru, + hi, am, ka, el, he, ja, km, ko, my, pl, zh, si, ta, te, th, + bo, tr, vi. + +Images (document blocks): + { "type": "image", "src": "logo.png" } path, resolved relative to the + --input JSON's directory + { "type": "image", "dataBase64": "…" } inline base64 (JPEG/PNG) + Print production (bleed/trimBox/marks/userUnit), outputIntent (ICC RGB) and + viewerPreferences (duplex, numCopies, printPageRange, pickTrayByPDFSize) are + set in the --layout JSON — see \`pdfnative schema render\`. Header / Footer: --header-left, --header-center, --header-right --footer-left, --footer-center, --footer-right - Each accepts a template string. {page}, {pages}, {date} are - substituted by pdfnative. + Each accepts a template string. {page}, {pages}, {date} and + {title} are substituted by pdfnative. Watermark: --watermark-text Text watermark @@ -162,6 +190,10 @@ Credentials (env wins over file flags): Algorithm: --algorithm rsa-sha256 (default) or ecdsa-sha256 (P-256 SEC1 keys). + --digest sha256 (default) | sha384 | sha512 — CMS digest. RSA only; + ecdsa is sha256-only. + --profile pkcs7 (default) or pades (ETSI.CAdES.detached, PAdES B-B: + ESS signing-certificate-v2, omits signing-time). Signing engine: --pure-crypto Force pdfnative's pure-JS RSA/ECDSA signer. By default the CLI @@ -175,14 +207,26 @@ Signature metadata (optional): --contact Contact info --signing-time ISO 8601 timestamp (default: now) -Long-term validation (LTV): - --timestamp RFC 3161 TSA URL for PAdES-T timestamping. NOT YET - available — embedding a timestamp token at signing time - requires upstream pdfnative support; the flag is reserved - and currently errors. Timestamp VALIDATION already works - via \`pdfnative verify\`. - -Security: key material is never written to logs or error messages. +Placement & multiple signatures: + --allow-multiple Allow signing an already-signed PDF (appends a second + signature field; default: idempotent single-signature) + --field-name Signature form-field name (default: auto) + --signature-rect "x1,y1,x2,y2" + Visible signature widget rectangle (PDF points) + --signature-page 1-based page for the signature widget (default: 1) + --placeholder-bytes + Explicit /Contents placeholder size (overrides the + automatic estimate) + +Trusted timestamp (PAdES B-T — OPT-IN NETWORK, SSRF-guarded): + --timestamp RFC 3161 TSA URL. Embeds a verified timestamp token + in the CMS unsigned attributes at signing time. + Combine with --profile pades for PAdES B-T. + --timestamp-digest sha256 (default) | sha384 | sha512 (TSA imprint) + --timestamp-nonce Request nonce (default: random 8 bytes) + +Security: key material is never written to logs or error messages. Without +--timestamp the CLI performs no network I/O. --help, -h Show this help message `; @@ -211,7 +255,7 @@ Options: strict a non-"good" status fails the signature --format, -f json (default) or text --summary Emit only the minimal verdict { valid, signatures, invalid } - --fields Comma-separated dot-paths to keep (e.g. valid,signatures.signatureValid) + --fields Comma-separated dot-paths to keep (e.g. allValid,signatures.signatureValid) --pretty Force indented JSON even under --json (agent mode is compact) --help, -h Show this help message @@ -224,8 +268,131 @@ Reported per signature: - RFC 3161 timestamp token validation (PAdES-T) - OCSP (RFC 6960) + CRL (RFC 5280) revocation status -Note: sign-side LTV (embedding timestamps / DSS into signatures) is tracked -upstream in pdfnative and is out of scope for this CLI. +Each signature also reports its form-field name, and /DocTimeStamp revisions +(PAdES B-LTA) are validated as RFC 3161 tokens (isDocTimestamp: true). +Sign-side LTV lives in \`pdfnative sign --timestamp\`, \`pdfnative ltv\` and +\`pdfnative doc-timestamp\`. +`; + +const LTV_USAGE = `\ +pdfnative ltv — PAdES B-LT: long-term validation data (/DSS + /VRI) + +Usage: + pdfnative ltv collect --input signed.pdf --online [--output ltv.json] + pdfnative ltv embed --input signed.pdf --data ltv.json [--output out.pdf] + pdfnative ltv add --input signed.pdf --online [--output out.pdf] + +Archives the certificates, OCSP responses and CRLs needed to validate the +document's signatures long after certificates expire (PAdES B-LT). The two-step +collect/embed flow supports air-gapped pipelines: collect on a connected +machine, embed offline. + +Subcommands: + collect Fetch validation data (OCSP/CRL) and write a replayable JSON file + (schema subject: ltv-data). REQUIRES --online. + embed Embed a previously collected JSON into /DSS + /VRI. NEVER performs + network I/O. + add collect + embed in one pass. REQUIRES --online. + +Options: + --input, -i Input PDF path (default: stdin) + --output, -o Output path (default: stdout) + --online Explicit opt-in for network fetches (SSRF-guarded, no + redirects). Without it, collect/add refuse to run — use + \`ltv embed\` for the offline half. + --prefer ocsp (default) | crl — preferred revocation source + --extra-cert PEM file with extra chain certificates (repeatable) + --data Collected JSON file (embed mode, required) + --timeout Network timeout in ms (default: 10000) + --dry-run Validate inputs; no output, no network + --help, -h Show this help message + +Typical PAdES ladder: + sign --timestamp --profile pades → B-T + ltv add --online → B-LT + doc-timestamp --url → B-LTA + ltv add --online → LTV for the doc-timestamp itself +`; + +const DOC_TIMESTAMP_USAGE = `\ +pdfnative doc-timestamp — PAdES B-LTA: RFC 3161 document timestamp + +Usage: + pdfnative doc-timestamp --input signed.pdf --url [--output out.pdf] + +Appends a /DocTimeStamp signature field (SubFilter /ETSI.RFC3161, ISO 32000-2 +§12.8.5) covering every byte of the document as an incremental revision — +earlier revisions stay byte-identical. Repeat periodically to renew LTA +protection. + +Options: + --input, -i Input PDF path (default: stdin) + --output, -o Output PDF path (default: stdout) + --url RFC 3161 TSA URL — REQUIRED (explicit network opt-in; + SSRF-guarded, no redirects) + --digest sha256 (default) | sha384 | sha512 + --field-name Timestamp field name (default: DocTimeStamp1, auto- + suffixed on collision) + --placeholder-bytes /Contents placeholder size (default: 12288) + --nonce Request nonce (default: random) + --timeout Network timeout in ms (default: 10000) + --dry-run Validate inputs; no output, no network + --help, -h Show this help message +`; + +const METADATA_USAGE = `\ +pdfnative metadata — Update PDF /Info + XMP metadata + +Usage: + pdfnative metadata --input in.pdf --title "New title" [--output out.pdf] + pdfnative metadata --input in.pdf --from-json meta.json --output out.pdf + +The update is an INCREMENTAL save: the original bytes are preserved as a +prefix, so existing digital signatures remain valid for their revision. The +XMP packet is kept in sync (xmp:ModifyDate, pdf:Keywords, …). + +Options: + --input, -i Input PDF path (default: stdin) + --output, -o Output PDF path (default: stdout) + --title Document title + --author Author + --subject Subject + --keywords Keywords (single string) + --mod-date ISO 8601 modification date (default: now — pass a fixed + value for reproducible output) + --from-json JSON file { title?, author?, subject?, keywords?, modDate? } + (mutually exclusive with the per-field flags) + --password Password for an encrypted PDF (env: PDFNATIVE_PASSWORD) + --dry-run Validate inputs without writing output + --help, -h Show this help message + +At least one metadata field is required. Reading metadata stays in +\`pdfnative inspect\`. +`; + +const COMPARE_USAGE = `\ +pdfnative compare — Diff two PDFs by text and structure + +Usage: + pdfnative compare a.pdf b.pdf [--mode both] [--format text|json] [options] + +Compares extracted reading-order text and/or document structure (page count, +page/print boxes, metadata, form fields, annotations, encryption, signatures). +Built for CI and agents: identical documents exit 0; any difference exits 1 +with the stable code E_CHECK_FAILED. Visual/rasterised diffing is out of scope +(pdfnative has no rasteriser). + +Options: + --mode text | structure | both (default: both) + --format, -f text (default) or json (report on stdout) + --tolerance Geometric tolerance in points for page/box sizes + (default: 0) + --ignore-whitespace Collapse runs of whitespace before the text diff + --pages 1-based selector limiting the text diff (e.g. "1,3-5") + --password-a Password for the first PDF + --password-b Password for the second PDF + --pretty Force indented JSON even under --json + --help, -h Show this help message `; const INSPECT_USAGE = `\ @@ -246,8 +413,11 @@ Options: --password Password for an encrypted PDF (env: PDFNATIVE_PASSWORD) --pdfua Include a PDF/UA (ISO 14289-1) structural validation report (valid + errors + warnings) + --signatures List signature fields (fieldName, subFilter, byteRange, + isDocTimestamp, isPlaceholder — never the signature bytes) --check Assert a property; repeatable; AND semantics; exits 1 on - failure. Values: pdfa | signed | encrypted | pdfua + failure. Values: pdfa | signed | encrypted | pdfua | + "signatures>=N" --summary Emit only the minimal verdict { pages, encrypted, signatures, pdfa } --fields Comma-separated dot-paths to keep (e.g. pageCount,metadata.title) --pretty Force indented JSON even under --json (agent mode is compact) @@ -255,25 +425,47 @@ Options: `; const BATCH_USAGE = `\ -pdfnative batch — Render every JSON file in a directory to PDF +pdfnative batch — Render a directory, or run a multi-command manifest pipeline Usage: pdfnative batch --input-dir --output-dir [render options] + pdfnative batch --manifest tasks.json [--allow-network] [--continue-on-error] -Options: +Directory mode: --input-dir Directory of *.json document definitions (required) --output-dir Directory for the rendered *.pdf files (created if absent) --concurrency Maximum parallel renders (default: 4) --fail-fast Stop at the first failure (default: render all, then report) + +Manifest mode (mutually exclusive with --input-dir): + --manifest Declarative pipeline file (schema subject: batch-manifest): + { "version": 1, "tasks": [ { "id", "command", "flags" } ] } + Flag values "@" reference the output of an EARLIER + task; relative paths resolve against the manifest's + directory. Tasks run sequentially, fail-fast. + Allowed commands: render, sign, verify, inspect, merge, + split, extract, extract-text, fill, encrypt, decrypt, + annotate, metadata, doc-timestamp. (ltv and compare need + positional arguments and are not yet manifest-callable.) + --allow-network Required for any network flag inside the manifest + (--timestamp, --url, --online, --revocation online). An + untrusted manifest can never trigger network I/O on its + own. + --continue-on-error + Keep running after a failure; tasks depending (via @) on a + failed task are skipped. + +Common: --format, -f Summary format: text (default) or json --summary Emit only the minimal verdict { total, succeeded, failed } --fields Comma-separated dot-paths to keep (e.g. total,failed) --pretty Force indented JSON even under --json (agent mode is compact) + --dry-run Validate the manifest / inputs without executing --help, -h Show this help message -All other flags (--variant, --layout, --page-size, --tagged, --compress, -smart-table flags, …) are forwarded to each render. Per-file --input/--output -are managed automatically. Exit code 1 if any file fails. +In directory mode all other flags (--variant, --layout, --page-size, --tagged, +--compress, smart-table flags, …) are forwarded to each render. Exit code 1 if +any file or task fails. `; const MERGE_USAGE = `\ @@ -512,11 +704,13 @@ Options: --input, -i Input PDF path (default: stdin) --output, -o Output PDF path (default: stdout) --annotations Path to the annotations JSON (required) + --password Password for an encrypted PDF (env: PDFNATIVE_PASSWORD) --dry-run Validate inputs without writing output --help, -h Show this help message The document is updated with an incremental save, so the original bytes — and -any existing signature — are preserved. +any existing signature — are preserved. Encrypted PDFs are supported via +--password (appended objects are encrypted under the existing scheme). `; const GOVERN_USAGE = `\ @@ -561,6 +755,10 @@ Subjects: verify-summary Output of \`verify --summary\` batch-summary Output of \`batch --summary\` govern-verify Output of \`govern verify-issue --format json\` + metadata Input for \`metadata --from-json\` + ltv-data Output of \`ltv collect\` / input for \`ltv embed\` + compare Output of \`compare --format json\` + batch-manifest Input for \`batch --manifest\` status Agent success envelope (write commands, --json) manifest Machine-readable capability manifest (commands, flags, codes) doctor Output of \`doctor --format json\` @@ -634,6 +832,22 @@ async function loadCommand(name: string): Promise { const m = await import('./commands/annotate.js'); return m.annotate; } + case 'metadata': { + const m = await import('./commands/metadata.js'); + return m.metadata; + } + case 'compare': { + const m = await import('./commands/compare.js'); + return m.compare; + } + case 'ltv': { + const m = await import('./commands/ltv.js'); + return m.ltv; + } + case 'doc-timestamp': { + const m = await import('./commands/docTimestamp.js'); + return m.docTimestamp; + } case 'govern': { const m = await import('./commands/govern.js'); return m.govern; @@ -682,6 +896,19 @@ async function main(): Promise { process.env['PDFNATIVE_DRY_RUN'] = '1'; } + // Global inflate cap for parsing untrusted PDFs (anti zip-bomb). Applied + // before dispatch so every reading command inherits it. Dynamic import + // keeps --help / --version startup free of the pdfnative module cost. + const maxInflate = getStringFlag(args.flags, 'max-inflate-size'); + if (maxInflate !== undefined) { + const n = Number(maxInflate); + if (!Number.isInteger(n) || n <= 0) { + throw new CliError('--max-inflate-size expects a positive integer byte count', 2); + } + const bridge = await import('./core-bridge/index.js'); + bridge.setMaxInflateOutputSize(n); + } + if (hasFlag(args.flags, 'help', 'h') && args.positionals.length === 0) { process.stdout.write(USAGE); process.exit(0); @@ -720,6 +947,10 @@ async function main(): Promise { case 'encrypt': process.stdout.write(ENCRYPT_USAGE); break; case 'decrypt': process.stdout.write(DECRYPT_USAGE); break; case 'annotate': process.stdout.write(ANNOTATE_USAGE); break; + case 'metadata': process.stdout.write(METADATA_USAGE); break; + case 'compare': process.stdout.write(COMPARE_USAGE); break; + case 'ltv': process.stdout.write(LTV_USAGE); break; + case 'doc-timestamp': process.stdout.write(DOC_TIMESTAMP_USAGE); break; case 'govern': process.stdout.write(GOVERN_USAGE); break; case 'batch': process.stdout.write(BATCH_USAGE); break; case 'schema': process.stdout.write(SCHEMA_USAGE); break; diff --git a/src/utils/agent.ts b/src/utils/agent.ts index e9b552a..40233d5 100644 --- a/src/utils/agent.ts +++ b/src/utils/agent.ts @@ -48,6 +48,7 @@ const DEFAULT_MESSAGE: Readonly> = { [ErrorCode.POLICY]: 'AI-governance policy violation', [ErrorCode.UNSUPPORTED]: 'unsupported operation', [ErrorCode.PASSWORD]: 'missing or incorrect password', + [ErrorCode.NETWORK]: 'opt-in network operation failed', [ErrorCode.RUNTIME]: 'runtime error', }; diff --git a/src/utils/cms-verify.ts b/src/utils/cms-verify.ts index 3a8f4ef..1658889 100644 --- a/src/utils/cms-verify.ts +++ b/src/utils/cms-verify.ts @@ -47,6 +47,14 @@ const OID_SHA384_RSA = '1.2.840.113549.1.1.12'; const OID_SHA512_RSA = '1.2.840.113549.1.1.13'; /** ECDSA with SHA-256 — ecdsa-with-SHA256. */ const OID_ECDSA_SHA256 = '1.2.840.10045.4.3.2'; +/** ECDSA with SHA-384 / SHA-512 (recognised; verification is P-256+SHA-256 only). */ +const OID_ECDSA_SHA384 = '1.2.840.10045.4.3.3'; +const OID_ECDSA_SHA512 = '1.2.840.10045.4.3.4'; +/** NIST digest-algorithm OIDs (SignerInfo digestAlgorithm) + legacy SHA-1. */ +const OID_DIGEST_SHA256 = '2.16.840.1.101.3.4.2.1'; +const OID_DIGEST_SHA384 = '2.16.840.1.101.3.4.2.2'; +const OID_DIGEST_SHA512 = '2.16.840.1.101.3.4.2.3'; +const OID_DIGEST_SHA1 = '1.3.14.3.2.26'; /** id-data — ContentInfo content type. */ const OID_DATA = '1.2.840.113549.1.7.1'; /** PKCS#9 message-digest signed attribute. */ @@ -171,6 +179,7 @@ function findSignerInfo(buf: Uint8Array, root: AbsNode): AbsNode | null { interface ParsedSignerInfo { readonly signedAttrsRaw: Uint8Array | null; // includes [0] IMPLICIT tag header + readonly digestAlgorithmOid: string | null; readonly signatureAlgorithmOid: string | null; readonly signatureValue: Uint8Array | null; readonly unsignedAttrsRaw: Uint8Array | null; @@ -191,12 +200,14 @@ function parseSignerInfo(cmsBytes: Uint8Array, signerInfo: AbsNode): ParsedSigne if (signerInfo.tag !== 0x30) { return { signedAttrsRaw: null, + digestAlgorithmOid: null, signatureAlgorithmOid: null, signatureValue: null, unsignedAttrsRaw: null, }; } let signedAttrsRaw: Uint8Array | null = null; + let digestAlgorithmOid: string | null = null; let signatureAlgorithmOid: string | null = null; let signatureValue: Uint8Array | null = null; let unsignedAttrsRaw: Uint8Array | null = null; @@ -210,17 +221,23 @@ function parseSignerInfo(cmsBytes: Uint8Array, signerInfo: AbsNode): ParsedSigne } else if (child.tag === 0xa1) { unsignedAttrsRaw = sliceNode(cmsBytes, child); } else if (child.tag === 0x30 && !sigAlgSeen) { - // First plain SEQUENCE we encounter AFTER signedAttrs must be the - // signatureAlgorithm. (digestAlgorithm appears before signedAttrs.) if (signedAttrsRaw !== null) { + // First plain SEQUENCE we encounter AFTER signedAttrs must be + // the signatureAlgorithm. signatureAlgorithmOid = oidFromAbs(cmsBytes, child.children[0] as AbsNode); sigAlgSeen = true; + } else if (child.children.length > 0) { + // Before signedAttrs the only SEQUENCE whose first child is an + // OID is the digestAlgorithm AlgorithmIdentifier (the sid + // SEQUENCE starts with a Name SEQUENCE, never an OID). + const oid = oidFromAbs(cmsBytes, child.children[0] as AbsNode); + if (oid !== null) digestAlgorithmOid = oid; } } else if (child.tag === 0x04 && sigAlgSeen) { signatureValue = sliceContent(cmsBytes, child); } } - return { signedAttrsRaw, signatureAlgorithmOid, signatureValue, unsignedAttrsRaw }; + return { signedAttrsRaw, digestAlgorithmOid, signatureAlgorithmOid, signatureValue, unsignedAttrsRaw }; } // ── signedAttrs DER re-encoding for hashing (RFC 5652 §5.4) ─────────── @@ -284,17 +301,69 @@ export function hasTimestampToken(unsignedAttrsRaw: Uint8Array | null): boolean // ── Signature-value verification ────────────────────────────────────── +/** Digest names recognised in CMS `digestAlgorithms` / SignerInfo digestAlgorithm. */ +export type CmsDigestName = 'sha1' | 'sha256' | 'sha384' | 'sha512'; + +/** Signature-algorithm labels the verifier can detect and report. */ +export type CmsSignatureAlgorithm = + | 'rsa-sha256' + | 'rsa-sha384' + | 'rsa-sha512' + | 'ecdsa-sha256' + | 'ecdsa-sha384' + | 'ecdsa-sha512'; + +const DIGEST_NAME_BY_OID: Readonly> = { + [OID_DIGEST_SHA256]: 'sha256', + [OID_DIGEST_SHA384]: 'sha384', + [OID_DIGEST_SHA512]: 'sha512', + [OID_DIGEST_SHA1]: 'sha1', +}; + +/** RSA signatureAlgorithm OIDs whose digest is implied by the OID itself. */ +const RSA_SIG_DIGEST_BY_OID: Readonly> = { + [OID_SHA256_RSA]: 'sha256', + [OID_SHA384_RSA]: 'sha384', + [OID_SHA512_RSA]: 'sha512', +}; + +/** ECDSA signatureAlgorithm OIDs → digest label (only SHA-256 is verifiable). */ +const ECDSA_SIG_DIGEST_BY_OID: Readonly> = { + [OID_ECDSA_SHA256]: 'sha256', + [OID_ECDSA_SHA384]: 'sha384', + [OID_ECDSA_SHA512]: 'sha512', +}; + export interface CmsVerifyResult { /** True iff the signature value verifies against the signed attributes. */ readonly signatureValid: boolean; /** Detected algorithm. `null` when unknown / unsupported. */ - readonly algorithm: 'rsa-sha256' | 'ecdsa-sha256' | null; + readonly algorithm: CmsSignatureAlgorithm | null; /** True when an RFC 3161 timestamp token is present (NOT validated). */ readonly timestampPresent: boolean; /** Human-readable diagnostic for failures or unsupported flows. */ readonly note: string | null; } +/** + * Extract the SignerInfo `digestAlgorithm` of a CMS SignedData as a + * node:crypto digest name (`sha1 | sha256 | sha384 | sha512`), or `null` + * when absent/unknown. The `verify` command uses this to hash the PDF + * /ByteRange with the same digest the signer used for `messageDigest`. + */ +export function extractSignerDigestAlgorithm(cmsBytes: Uint8Array): CmsDigestName | null { + let root: AbsNode; + try { + root = walkAbs(cmsBytes); + } catch { + return null; + } + const signerInfo = findSignerInfo(cmsBytes, root); + if (signerInfo === null) return null; + const oid = parseSignerInfo(cmsBytes, signerInfo).digestAlgorithmOid; + return oid !== null ? DIGEST_NAME_BY_OID[oid] ?? null : null; +} + /** * Verify the CMS signature value against the signed attributes for a single * SignerInfo, using the leaf certificate's public key. @@ -361,38 +430,62 @@ export function verifyCmsSignatureValue( } const oid = parsed.signatureAlgorithmOid; - if (oid === OID_SHA256_RSA || oid === OID_RSA_ENCRYPTION) { + const impliedRsaDigest = oid !== null ? RSA_SIG_DIGEST_BY_OID[oid] : undefined; + if (impliedRsaDigest !== undefined || oid === OID_RSA_ENCRYPTION) { + // With a bare rsaEncryption signatureAlgorithm the digest comes from + // the SignerInfo digestAlgorithm (default SHA-256). SHA-1 is not a + // supported RSASSA digest here — fall back to SHA-256 (which then + // fails verification, as it should for a legacy algorithm). + const signerDigest = parsed.digestAlgorithmOid !== null + ? DIGEST_NAME_BY_OID[parsed.digestAlgorithmOid] + : undefined; + const rsaDigest: 'sha256' | 'sha384' | 'sha512' = impliedRsaDigest + ?? (signerDigest === 'sha384' || signerDigest === 'sha512' ? signerDigest : 'sha256'); + const algorithm: CmsSignatureAlgorithm = `rsa-${rsaDigest}`; let pubKey: RsaPublicKey; try { pubKey = rsaPubKeyFromCert(leafCert); } catch (e) { return { signatureValid: false, - algorithm: 'rsa-sha256', + algorithm, timestampPresent, note: e instanceof Error ? e.message : 'RSA public key extraction failed', }; } - const hash = createHash('sha256').update(signedAttrsForHash).digest(); + const hash = createHash(rsaDigest).update(signedAttrsForHash).digest(); let valid = false; try { - valid = rsaVerifyHash(new Uint8Array(hash), parsed.signatureValue, pubKey); + valid = rsaVerifyHash(new Uint8Array(hash), parsed.signatureValue, pubKey, rsaDigest); } catch (e) { return { signatureValid: false, - algorithm: 'rsa-sha256', + algorithm, timestampPresent, note: e instanceof Error ? e.message : 'RSA verification threw', }; } return { signatureValid: valid, - algorithm: 'rsa-sha256', + algorithm, timestampPresent, note: valid ? null : 'RSA signature value mismatch', }; } + if (oid === OID_ECDSA_SHA384 || oid === OID_ECDSA_SHA512) { + // Recognised but NOT verifiable: pdfnative's ecdsaVerify is P-256 + + // SHA-256 only (the sign side enforces the same limit). Report the + // detected algorithm so the caller can surface an actionable note. + const digestLabel = ECDSA_SIG_DIGEST_BY_OID[oid] as 'sha384' | 'sha512'; + return { + signatureValid: false, + algorithm: `ecdsa-${digestLabel}`, + timestampPresent, + note: `ECDSA with ${digestLabel.toUpperCase()} is not supported (verification is P-256 + SHA-256 only)`, + }; + } + if (oid === OID_ECDSA_SHA256) { // For ECDSA, ecdsaVerify takes the message bytes (it hashes internally). let r: bigint; diff --git a/src/utils/error.ts b/src/utils/error.ts index 04e1d66..9e88907 100644 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -25,6 +25,8 @@ export const ErrorCode = { UNSUPPORTED: 'E_UNSUPPORTED', /** Encrypted PDF: password missing or incorrect (encrypt/decrypt/read). */ PASSWORD: 'E_PASSWORD', + /** Opt-in network operation failed (TSA / OCSP / CRL fetch). */ + NETWORK: 'E_NETWORK', /** Catch-all runtime error (exit 1). */ RUNTIME: 'E_RUNTIME', } as const; diff --git a/src/utils/keys.ts b/src/utils/keys.ts index a52ce79..3a767eb 100644 --- a/src/utils/keys.ts +++ b/src/utils/keys.ts @@ -18,6 +18,7 @@ import type { X509Certificate, Asn1Node, CryptoProvider, + SignatureAlgorithm, } from '../core-bridge/index.js'; import { validatePath } from './io.js'; import { CliError, ErrorCode } from './error.js'; @@ -109,8 +110,10 @@ export async function loadPemChain( * math instead of the pure-JS bignum path. * * The provider signs the DER-encoded CMS `SignedAttributes`: `createSign` - * hashes them with SHA-256 internally and returns the correct encoding for the - * key type (RSASSA-PKCS1-v1_5 for RSA, DER-encoded ECDSA for EC keys). + * hashes them internally with the digest implied by the requested + * `SignatureAlgorithm` (SHA-256 by default; SHA-384/512 for `rsa-sha384` / + * `rsa-sha512`) and returns the correct encoding for the key type + * (RSASSA-PKCS1-v1_5 for RSA, DER-encoded ECDSA for EC keys). * * Security: the `KeyObject` is created once and captured in the closure; the * PEM string is never referenced again and never appears in error messages. @@ -127,9 +130,12 @@ export function createNativeCryptoProvider(pem: string): CryptoProvider { ); } return { - sign(tbs: Uint8Array): Uint8Array { + sign(tbs: Uint8Array, algorithm?: SignatureAlgorithm): Uint8Array { + const hash = algorithm === 'rsa-sha384' ? 'sha384' + : algorithm === 'rsa-sha512' ? 'sha512' + : 'sha256'; try { - return new Uint8Array(createSign('sha256').update(tbs).sign(keyObject)); + return new Uint8Array(createSign(hash).update(tbs).sign(keyObject)); } catch { // Never surface the underlying message — it may reference key bytes. throw new CliError('Failed to sign PDF.', 1, ErrorCode.SIGN); diff --git a/src/utils/layout.ts b/src/utils/layout.ts index 4f18ea6..653570f 100644 --- a/src/utils/layout.ts +++ b/src/utils/layout.ts @@ -87,6 +87,20 @@ export async function loadLayoutFile( return rest; }); } + // Revive outputIntent.iccProfile (pdfnative 1.7.0 CustomOutputIntent): + // JSON can only carry a number array, but the engine expects Uint8Array. + // ICC profiles are not executable payloads and the engine validates the + // 128-byte header + RGB colour space before embedding. + const oi = obj.outputIntent; + if (typeof oi === 'object' && oi !== null && !Array.isArray(oi)) { + const oiRec = oi as Record; + if (Array.isArray(oiRec.iccProfile)) { + obj.outputIntent = { + ...oiRec, + iccProfile: Uint8Array.from(oiRec.iccProfile as readonly number[]), + }; + } + } return obj as Partial; } diff --git a/src/utils/ltv-provider.ts b/src/utils/ltv-provider.ts new file mode 100644 index 0000000..37f562d --- /dev/null +++ b/src/utils/ltv-provider.ts @@ -0,0 +1,71 @@ +// OCSP / CRL revocation transport for LTV collection (opt-in network, +// SSRF-guarded). +// +// pdfnative's LTV collector (`collectValidationInfo` / `addValidationInfo`) +// never touches the network: it extracts OCSP responder and CRL distribution +// point URLs from certificate extensions and hands them to an injected +// {@link RevocationProvider}. This module is the CLI-side transport — every +// round-trip goes through the same SSRF guard as `verify --revocation online` +// and the TSA transport (src/utils/tsa.ts). +// +// Network happens ONLY when the user passed the explicit `--online` flag +// (`ltv collect --online` / `ltv add --online`). + +import type { RevocationProvider } from '../core-bridge/index.js'; +import { guardedFetch, FetchGuardError } from './fetch-guard.js'; +import { CliError, ErrorCode } from './error.js'; + +export interface RevocationProviderOptions { + /** Request timeout in milliseconds. Default 10000 (fetch-guard default). */ + readonly timeoutMs?: number; +} + +/** + * Build a {@link RevocationProvider} that POSTs OCSPRequest DERs and GETs + * CRLs through the SSRF-guarded fetch. + * + * Failures are deliberately generic (`E_NETWORK`) and never include response + * bodies — a hostile responder must not be able to inject text into CLI + * output. + */ +export function createRevocationProvider(options: RevocationProviderOptions = {}): RevocationProvider { + return { + async fetchOcsp(url: string, request: Uint8Array): Promise { + let result; + try { + result = await guardedFetch(url, { + method: 'POST', + body: request, + contentType: 'application/ocsp-request', + accept: 'application/ocsp-response', + timeoutMs: options.timeoutMs, + }); + } catch (err) { + const reason = err instanceof FetchGuardError ? `: ${err.message}` : ''; + throw new CliError(`OCSP responder request failed${reason}`, 1, ErrorCode.NETWORK); + } + if (result.status !== 200) { + throw new CliError(`OCSP responder returned HTTP ${result.status}`, 1, ErrorCode.NETWORK); + } + return result.body; + }, + + async fetchCrl(url: string): Promise { + let result; + try { + result = await guardedFetch(url, { + method: 'GET', + accept: 'application/pkix-crl', + timeoutMs: options.timeoutMs, + }); + } catch (err) { + const reason = err instanceof FetchGuardError ? `: ${err.message}` : ''; + throw new CliError(`CRL distribution point request failed${reason}`, 1, ErrorCode.NETWORK); + } + if (result.status !== 200) { + throw new CliError(`CRL distribution point returned HTTP ${result.status}`, 1, ErrorCode.NETWORK); + } + return result.body; + }, + }; +} diff --git a/src/utils/manifest.ts b/src/utils/manifest.ts new file mode 100644 index 0000000..3878e56 --- /dev/null +++ b/src/utils/manifest.ts @@ -0,0 +1,305 @@ +// Batch manifest — parse & validate a `batch --manifest tasks.json` pipeline. +// +// A manifest declares an ordered list of tasks, each invoking one whitelisted +// CLI command with a flat flag map. Validation is STRICT and happens before +// any execution: +// • structural violations (wrong shape/types/version) → exit 2, E_USAGE +// • value violations (bad id, duplicate id, unknown command, bad @ref) +// → exit 1, E_INPUT +// • network-reaching flags without --allow-network → exit 2, E_USAGE +// +// Relative paths in path-carrying flags resolve against the DIRECTORY of the +// manifest file (after the same traversal check the CLI applies to direct +// flags). A flag value "@" references the resolved `output` of an EARLIER +// task and is substituted with that path. Everything here is pure data +// validation — no filesystem I/O, no imports of command modules. + +import { dirname, isAbsolute, resolve } from 'node:path'; +import { CliError, ErrorCode } from './error.js'; +import { validatePath } from './io.js'; + +/** + * Commands a manifest task may invoke. Meta/orchestration commands never are. + * `ltv` and `compare` are excluded for now: manifest tasks carry only flags, + * and both commands require positional arguments (subcommand / two PDF paths) + * — tracked in ROADMAP as "positional args in manifest tasks". + */ +export const MANIFEST_COMMANDS: ReadonlySet = new Set([ + 'render', 'sign', 'verify', 'inspect', 'merge', 'split', 'extract', + 'extract-text', 'fill', 'encrypt', 'decrypt', 'annotate', + 'metadata', 'doc-timestamp', +]); + +/** Hard cap on manifest size in tasks (DoS guard; far above any real pipeline). */ +const MAX_TASKS = 1000; + +/** Explicitly forbidden (meta / orchestration) — called out in the error. */ +const FORBIDDEN_COMMANDS: ReadonlySet = new Set([ + 'batch', 'govern', 'schema', 'completion', 'doctor', +]); + +const ID_RE = /^[A-Za-z0-9_-]+$/; + +/** Flags whose relative values resolve against the manifest directory. */ +const PATH_FLAGS: ReadonlySet = new Set([ + 'input', 'i', 'output', 'o', 'output-dir', + 'key', 'cert', 'cert-chain', 'trust', + 'data', 'annotations', 'template', 'layout', 'watermark-image', +]); + +export interface ManifestTaskPlan { + readonly id: string; + readonly command: string; + /** Fully resolved flags (paths absolute, @refs substituted), ParsedArgs-shaped. */ + readonly flags: Readonly>; + /** Resolved output path when the task declares --output. */ + readonly output: string | undefined; + /** Directory the task writes into (created with mkdir -p before running). */ + readonly outputDir: string | undefined; + /** ids of earlier tasks referenced via "@id" values. */ + readonly dependsOn: readonly string[]; + /** Name of a network-reaching flag the task carries, if any. */ + readonly networkFlag: string | undefined; +} + +export interface ManifestPlan { + readonly tasks: readonly ManifestTaskPlan[]; +} + +function usageError(message: string): CliError { + return new CliError(message, 2, ErrorCode.USAGE); +} + +function inputError(message: string): CliError { + return new CliError(message, 1, ErrorCode.INPUT); +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** Detect a network-reaching flag on a task. Returns the flag name or undefined. */ +function detectNetworkFlag( + command: string, + flags: Readonly>, +): string | undefined { + if (flags['timestamp'] !== undefined) return 'timestamp'; + if (command === 'doc-timestamp' && flags['url'] !== undefined) return 'url'; + if (flags['online'] !== undefined) return 'online'; + const revocation = flags['revocation']; + if (typeof revocation === 'string' && revocation.trim().toLowerCase() === 'online') { + return 'revocation'; + } + return undefined; +} + +/** + * Resolve one string flag value: substitute an "@id" reference with the + * referenced task's output path, or resolve a relative path flag against the + * manifest directory. Records @-dependencies in `dependsOn`. + */ +function resolveValue( + value: string, + key: string, + taskId: string, + manifestDir: string, + priorOutputs: ReadonlyMap, + dependsOn: Set, +): string { + if (value.startsWith('@')) { + const refId = value.slice(1); + if (!priorOutputs.has(refId)) { + throw inputError( + `Task "${taskId}": flag "${key}" references "@${refId}", which is not an ` + + 'EARLIER task in the manifest (forward and unknown references are not allowed).', + ); + } + const refOutput = priorOutputs.get(refId); + if (refOutput === undefined) { + throw inputError( + `Task "${taskId}": flag "${key}" references "@${refId}", but task "${refId}" ` + + 'declares no "output" flag to reference.', + ); + } + dependsOn.add(refId); + return refOutput; + } + if (PATH_FLAGS.has(key)) { + // Same traversal check the CLI applies to direct path flags — a + // manifest must not accept a relative escape the command line rejects. + validatePath(value); + if (!isAbsolute(value)) { + return resolve(manifestDir, value); + } + } + return value; +} + +/** Validate + resolve one task's flag map into ParsedArgs-shaped flags. */ +function resolveFlags( + taskId: string, + rawFlags: Record, + manifestDir: string, + priorOutputs: ReadonlyMap, +): { flags: Record; dependsOn: readonly string[] } { + const flags: Record = {}; + const dependsOn = new Set(); + + for (const [key, value] of Object.entries(rawFlags)) { + if (key.length === 0 || key.startsWith('-') || /[\s=]/.test(key)) { + throw usageError( + `Task "${taskId}": invalid flag name "${key}" (use the bare flag name, ` + + 'without leading dashes, whitespace or "=").', + ); + } + if (typeof value === 'boolean') { + // true → bare `--key`; false → the flag is simply omitted, matching + // the argv conversion contract (there is no `--key false` form). + if (value) flags[key] = true; + continue; + } + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw usageError(`Task "${taskId}": flag "${key}" must be a finite number.`); + } + flags[key] = String(value); + continue; + } + if (typeof value === 'string') { + flags[key] = resolveValue(value, key, taskId, manifestDir, priorOutputs, dependsOn); + continue; + } + if (Array.isArray(value)) { + const items: string[] = []; + for (const item of value as unknown[]) { + if (typeof item !== 'string') { + throw usageError( + `Task "${taskId}": flag "${key}" must be an array of strings.`, + ); + } + items.push(resolveValue(item, key, taskId, manifestDir, priorOutputs, dependsOn)); + } + flags[key] = items; + continue; + } + throw usageError( + `Task "${taskId}": flag "${key}" has an unsupported value type — allowed: ` + + 'string, number, boolean, string[].', + ); + } + + return { flags, dependsOn: [...dependsOn] }; +} + +/** + * Parse and strictly validate a batch manifest. + * + * @param raw - Raw manifest file content (JSON text). + * @param manifestDir - Absolute directory of the manifest file (path anchor). + */ +export function parseManifest(raw: string, manifestDir: string): ManifestPlan { + let doc: unknown; + try { + doc = JSON.parse(raw); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + throw new CliError(`Manifest is not valid JSON: ${message}`, 1, ErrorCode.PARSE); + } + + if (!isPlainObject(doc)) { + throw usageError('Manifest must be a JSON object { "version": 1, "tasks": [...] }.'); + } + if (doc['version'] !== 1) { + throw usageError( + `Unsupported manifest "version": ${JSON.stringify(doc['version'])}. This CLI supports version 1.`, + ); + } + const tasksRaw = doc['tasks']; + if (!Array.isArray(tasksRaw) || tasksRaw.length === 0) { + throw usageError('Manifest "tasks" must be a non-empty array.'); + } + if (tasksRaw.length > MAX_TASKS) { + throw usageError( + `Manifest declares ${tasksRaw.length} tasks — the maximum is ${MAX_TASKS}.`, + ); + } + + const tasks: ManifestTaskPlan[] = []; + // id → resolved output path (undefined when the task has no --output). + const priorOutputs = new Map(); + + for (const [index, taskRaw] of tasksRaw.entries()) { + if (!isPlainObject(taskRaw)) { + throw usageError(`Manifest task #${index + 1} must be an object.`); + } + const id = taskRaw['id']; + if (typeof id !== 'string' || id.length === 0) { + throw usageError(`Manifest task #${index + 1}: "id" must be a non-empty string.`); + } + if (!ID_RE.test(id)) { + throw inputError( + `Manifest task "${id}": invalid id — allowed characters: A-Z a-z 0-9 _ -`, + ); + } + if (priorOutputs.has(id)) { + throw inputError(`Manifest task id "${id}" is duplicated — ids must be unique.`); + } + const command = taskRaw['command']; + if (typeof command !== 'string' || command.length === 0) { + throw usageError(`Manifest task "${id}": "command" must be a non-empty string.`); + } + if (!MANIFEST_COMMANDS.has(command)) { + const reason = FORBIDDEN_COMMANDS.has(command) + ? `"${command}" is a meta/orchestration command and is never allowed in a manifest` + : `"${command}" is not a whitelisted manifest command`; + throw inputError( + `Manifest task "${id}": ${reason}. Allowed: ${[...MANIFEST_COMMANDS].join(', ')}.`, + ); + } + const flagsRaw = taskRaw['flags'] ?? {}; + if (!isPlainObject(flagsRaw)) { + throw usageError(`Manifest task "${id}": "flags" must be an object.`); + } + + const { flags, dependsOn } = resolveFlags(id, flagsRaw, manifestDir, priorOutputs); + + const outputValue = flags['output'] ?? flags['o']; + const output = typeof outputValue === 'string' ? outputValue : undefined; + const outputDirValue = flags['output-dir']; + const outputDir = typeof outputDirValue === 'string' + ? outputDirValue + : (output !== undefined ? dirname(output) : undefined); + + tasks.push({ + id, + command, + flags, + output, + outputDir, + dependsOn, + networkFlag: detectNetworkFlag(command, flags), + }); + priorOutputs.set(id, output); + } + + return { tasks }; +} + +/** + * Enforce the offline-by-default policy: an untrusted manifest must not be + * able to trigger network access. Any network-reaching task flag requires the + * batch invocation itself to carry `--allow-network`. + */ +export function assertOfflinePolicy(plan: ManifestPlan, allowNetwork: boolean): void { + if (allowNetwork) return; + for (const task of plan.tasks) { + if (task.networkFlag !== undefined) { + throw usageError( + `Manifest task "${task.id}" carries the network-reaching flag ` + + `"${task.networkFlag}", but batch was invoked without --allow-network. ` + + 'Manifests are offline by default; pass --allow-network to permit ' + + 'network access for this pipeline.', + ); + } + } +} diff --git a/src/utils/timestamp-verify.ts b/src/utils/timestamp-verify.ts index c395c16..fb7d86c 100644 --- a/src/utils/timestamp-verify.ts +++ b/src/utils/timestamp-verify.ts @@ -23,11 +23,13 @@ import { extractEContent, extractCmsCertificates, extractSignedMessageDigest, + extractSignerDigestAlgorithm, decodeOid, + type CmsSignatureAlgorithm, } from './cms-verify.js'; import { buildChain, isTrustedRoot } from './cert-chain.js'; -import { parseCertificate } from '../core-bridge/index.js'; -import type { X509Certificate } from '../core-bridge/index.js'; +import { parseCertificate, parseTimestampToken, verifyTimestampImprint } from '../core-bridge/index.js'; +import type { X509Certificate, TstInfo as ParsedTstInfo } from '../core-bridge/index.js'; // ── OID constants ───────────────────────────────────────────────────── @@ -269,3 +271,201 @@ export function verifyTimestamp( note: null, }; } + +// ────────────────────────────────────────────────────────────────────── +// /DocTimeStamp revision validation (PAdES B-LTA, ISO 32000-2 §12.8.5) +// +// A /DocTimeStamp signature field's /Contents is NOT a classic CMS document +// signature: it is a bare RFC 3161 TimeStampToken whose messageImprint +// covers the /ByteRange of the timestamped revision. Verification proves: +// 1. the TSTInfo messageImprint equals hash(/ByteRange bytes) computed +// with the TSTInfo's own hash algorithm (byte-range binding); +// 2. the TSA SignerInfo signature over its signedAttrs is valid, and the +// signed messageDigest matches the encapsulated TSTInfo (both +// delegated to the same cms-verify machinery PAdES-T reuses); +// 3. the TSA certificate chain builds and (optionally) anchors to trust — +// reported, but like PAdES-T never folded into `valid`. +// ────────────────────────────────────────────────────────────────────── + +/** Decode raw OID content bytes (no tag/length) to dotted-decimal. */ +function oidContentToString(bytes: Uint8Array): string | null { + if (bytes.length === 0) return null; + const first = bytes[0] as number; + const parts: number[] = [Math.floor(first / 40), first % 40]; + let v = 0; + for (let i = 1; i < bytes.length; i++) { + const byte = bytes[i] as number; + v = (v << 7) | (byte & 0x7f); + if ((byte & 0x80) === 0) { + parts.push(v); + v = 0; + } + } + return parts.join('.'); +} + +function toHex(bytes: Uint8Array): string { + let s = ''; + for (let i = 0; i < bytes.length; i++) { + s += (bytes[i] as number).toString(16).padStart(2, '0'); + } + return s; +} + +export interface DocTimestampVerifyResult { + /** True when both the byte-range imprint and the token signature verify. */ + readonly valid: boolean; + /** TSTInfo messageImprint == hash of the /ByteRange-covered bytes. */ + readonly imprintValid: boolean; + /** TSA SignerInfo signature over the TSTInfo is valid. */ + readonly signatureValid: boolean; + /** Detected TSA signature algorithm, when the token parsed. */ + readonly algorithm: CmsSignatureAlgorithm | null; + /** Hex of the recomputed byte-range hash (imprint digest), or null. */ + readonly imprintHex: string | null; + /** TSTInfo genTime as ISO 8601, or null. */ + readonly genTime: string | null; + /** TSA signer certificate subject CN, or null. */ + readonly tsaSubject: string | null; + /** True when the TSA chain resolved to a (self-signed) root. */ + readonly chainValid: boolean; + /** True when the TSA chain root is trusted (anchors or self-signed). */ + readonly trusted: boolean; + /** Diagnostic for failures; never leaks byte offsets. */ + readonly note: string | null; +} + +/** + * Verify a /DocTimeStamp revision's TimeStampToken against the PDF bytes. + * + * @param tokenBytes The /Contents value with trailing zero-padding already + * trimmed to the DER length of the outer ContentInfo. + * @param pdfBytes The complete PDF file bytes. + * @param byteRange The /ByteRange of the /DocTimeStamp signature dict. + * @param trustRoots Optional trust anchors for the TSA chain. + */ +export function verifyDocTimestamp( + tokenBytes: Uint8Array, + pdfBytes: Uint8Array, + byteRange: readonly [number, number, number, number], + trustRoots: readonly X509Certificate[], +): DocTimestampVerifyResult { + const fail = (note: string): DocTimestampVerifyResult => ({ + valid: false, + imprintValid: false, + signatureValid: false, + algorithm: null, + imprintHex: null, + genTime: null, + tsaSubject: null, + chainValid: false, + trusted: false, + note, + }); + + let info: ParsedTstInfo; + try { + info = parseTimestampToken(tokenBytes); + } catch { + return fail('failed to parse timestamp token (malformed or not RFC 3161)'); + } + const genTime = Number.isNaN(info.genTime.getTime()) ? null : info.genTime.toISOString(); + + // (1) Byte-range binding: recompute the imprint with the TSTInfo's own + // hash algorithm and byte-compare against the token's messageImprint. + const algOid = oidContentToString(info.hashAlgorithmOid); + const digestName = algOid !== null ? DIGEST_BY_OID[algOid] ?? null : null; + if (digestName === null) { + return fail('unsupported messageImprint hash algorithm in timestamp token'); + } + const [a, b, c, d] = byteRange; + const hash = createHash(digestName); + hash.update(pdfBytes.subarray(a, a + b)); + hash.update(pdfBytes.subarray(c, c + d)); + const imprint = new Uint8Array(hash.digest()); + const imprintValid = verifyTimestampImprint(info, imprint); + const imprintHex = toHex(imprint); + + // (2) TSA SignerInfo signature + eContent digest — same checks the + // PAdES-T verifier applies to a signature-time-stamp token. + let tsaCerts: X509Certificate[]; + try { + tsaCerts = (info.tsaCertificates.length > 0 + ? [...info.tsaCertificates] + : extractCmsCertificates(tokenBytes) + ).map((der) => parseCertificate(der)); + } catch { + tsaCerts = []; + } + if (tsaCerts.length === 0) { + return { + ...fail('no TSA certificate embedded in timestamp token'), + imprintValid, + imprintHex, + genTime, + }; + } + + // eContent integrity: signed messageDigest == digest(TSTInfo). + const encap = extractEContent(tokenBytes); + const tokenMd = extractSignedMessageDigest(tokenBytes); + if (encap !== null && tokenMd !== null) { + const mdDigest = extractSignerDigestAlgorithm(tokenBytes) ?? 'sha256'; + const eHash = new Uint8Array(createHash(mdDigest).update(encap.content).digest()); + if (!bytesEqual(tokenMd, eHash)) { + return { + ...fail('timestamp eContent digest mismatch'), + imprintValid, + imprintHex, + genTime, + }; + } + } + + // The TSA signer is whichever embedded cert verifies the token signature. + let tsaLeaf: X509Certificate | null = null; + let algorithm: CmsSignatureAlgorithm | null = null; + let sigNote: string | null = null; + for (const cand of tsaCerts) { + const r = verifyCmsSignatureValue(tokenBytes, cand); + if (r.signatureValid) { + tsaLeaf = cand; + algorithm = r.algorithm; + break; + } + sigNote = r.note; + } + const signatureValid = tsaLeaf !== null; + + // (3) TSA chain + trust — reported, never folded into `valid` (matches + // the PAdES-T timestamp verifier: TSA trust is a separate signal). + let chainValid = false; + let trusted = false; + let tsaSubject: string | null = null; + if (tsaLeaf !== null) { + const built = buildChain(tsaLeaf, tsaCerts.concat(trustRoots)); + chainValid = built.chainValid; + trusted = isTrustedRoot(built.root, trustRoots); + tsaSubject = cnOf(tsaLeaf); + } + + let note: string | null = null; + if (!imprintValid) { + note = 'timestamp messageImprint does not match the signed byte range'; + } else if (!signatureValid) { + note = `TSA signature invalid${sigNote !== null ? ` (${sigNote})` : ''}`; + } + + return { + valid: imprintValid && signatureValid, + imprintValid, + signatureValid, + algorithm, + imprintHex, + genTime, + tsaSubject, + chainValid, + trusted, + note, + }; +} diff --git a/src/utils/tsa.ts b/src/utils/tsa.ts new file mode 100644 index 0000000..acd3fe4 --- /dev/null +++ b/src/utils/tsa.ts @@ -0,0 +1,50 @@ +// RFC 3161 Time-Stamp Authority transport (opt-in network, SSRF-guarded). +// +// pdfnative's engine never opens a socket: `signPdfBytesWithTimestamp` and +// `addDocumentTimestamp` take an injected TimestampProvider. This module is +// the CLI-side transport — a single guarded HTTP(S) POST of the DER +// TimeStampReq (`application/timestamp-query`), returning the raw DER +// TimeStampResp for the engine to parse and verify. +// +// Network happens ONLY when the user passed an explicit TSA URL +// (`sign --timestamp ` / `doc-timestamp --url `), and every +// request goes through the same SSRF guard as `verify --revocation online`. + +import type { TimestampProvider } from '../core-bridge/index.js'; +import { guardedFetch, FetchGuardError } from './fetch-guard.js'; +import { CliError, ErrorCode } from './error.js'; + +export interface TsaProviderOptions { + /** Request timeout in milliseconds. Default 10000 (fetch-guard default). */ + readonly timeoutMs?: number; +} + +/** + * Build a {@link TimestampProvider} that POSTs the TimeStampReq to `url`. + * + * Failures are deliberately generic (`E_NETWORK`) and never include response + * bodies — a hostile TSA must not be able to inject text into CLI output. + */ +export function createTsaProvider(url: string, options: TsaProviderOptions = {}): TimestampProvider { + return { + async getTimestamp(request: Uint8Array): Promise { + let result; + try { + result = await guardedFetch(url, { + method: 'POST', + body: request, + contentType: 'application/timestamp-query', + accept: 'application/timestamp-reply', + timeoutMs: options.timeoutMs, + }); + } catch (err) { + const reason = err instanceof FetchGuardError ? `: ${err.message}` : ''; + throw new CliError(`timestamp authority request failed${reason}`, 1, ErrorCode.NETWORK); + } + if (result.status !== 200) { + throw new CliError(`timestamp authority returned HTTP ${result.status}`, 1, ErrorCode.NETWORK); + } + return result.body; + }, + }; +} diff --git a/tests/commands/annotate.test.ts b/tests/commands/annotate.test.ts index a9fac35..5b484c9 100644 --- a/tests/commands/annotate.test.ts +++ b/tests/commands/annotate.test.ts @@ -4,14 +4,16 @@ import * as path from 'node:path'; import * as fs from 'node:fs/promises'; import { render } from '../../src/commands/render.js'; import { annotate } from '../../src/commands/annotate.js'; +import { encrypt } from '../../src/commands/encrypt.js'; import { inspect } from '../../src/commands/inspect.js'; import { parseArgs } from '../../src/utils/args.js'; -import { CliError } from '../../src/utils/error.js'; +import { CliError, ErrorCode } from '../../src/utils/error.js'; const tmp: string[] = []; afterEach(async () => { vi.restoreAllMocks(); + delete process.env['PDFNATIVE_PASSWORD']; for (const f of tmp.splice(0)) { await fs.rm(f, { recursive: true, force: true }).catch(() => undefined); } @@ -39,14 +41,24 @@ async function writeAnnots(value: unknown): Promise { return p; } -async function annotationSubtypes(pdfPath: string): Promise { +async function annotationSubtypes(pdfPath: string, password?: string): Promise { const chunks: string[] = []; vi.spyOn(process.stdout, 'write').mockImplementation((c: unknown) => { chunks.push(String(c)); return true; }); - await inspect(parseArgs(['--input', pdfPath, '--format', 'json', '--annotations'])); + const argv = ['--input', pdfPath, '--format', 'json', '--annotations']; + if (password !== undefined) argv.push('--password', password); + await inspect(parseArgs(argv)); const res = JSON.parse(chunks.join('')); return (res.annotations ?? []).map((a: { subtype: string }) => a.subtype); } +/** Render a doc, then AES-encrypt it (owner 'o', user 'u') via the encrypt command. */ +async function renderEncryptedDoc(): Promise { + const plain = await renderDoc(); + const enc = tmpPath('doc-enc.pdf'); + await encrypt(parseArgs(['--input', plain, '--output', enc, '--owner-password', 'o', '--user-password', 'u'])); + return enc; +} + describe('annotate', () => { it('attaches markup annotations and preserves the original page', async () => { const doc = await renderDoc(); @@ -149,4 +161,56 @@ describe('annotate', () => { annotate(parseArgs(['--input', doc, '--annotations', notes, '--output', tmpPath('x.pdf')])), ).rejects.toBeInstanceOf(CliError); }); + + // ────────────────────────────────────────────────────────────────── + // v1.4.0 — encrypted sources via --password / $PDFNATIVE_PASSWORD + // ────────────────────────────────────────────────────────────────── + + describe('encrypted documents (--password)', () => { + it('annotates an encrypted PDF with --password', async () => { + const enc = await renderEncryptedDoc(); + const notes = await writeAnnots([ + { page: 1, type: 'highlight', rect: [72, 700, 520, 716], contents: 'secret note' }, + ]); + const out = tmpPath('annotated-enc.pdf'); + await annotate(parseArgs(['--input', enc, '--annotations', notes, '--output', out, '--password', 'u'])); + const bytes = await fs.readFile(out); + expect(bytes.subarray(0, 4).toString('ascii')).toBe('%PDF'); + expect(await annotationSubtypes(out, 'u')).toContain('Highlight'); + }); + + it('reads the password from $PDFNATIVE_PASSWORD when the flag is absent', async () => { + const enc = await renderEncryptedDoc(); + const notes = await writeAnnots([{ page: 1, type: 'square', rect: [10, 10, 50, 50] }]); + const out = tmpPath('annotated-env.pdf'); + process.env['PDFNATIVE_PASSWORD'] = 'u'; + try { + await annotate(parseArgs(['--input', enc, '--annotations', notes, '--output', out])); + } finally { + delete process.env['PDFNATIVE_PASSWORD']; + } + expect(await annotationSubtypes(out, 'u')).toContain('Square'); + }); + + it('fails with E_PASSWORD when the password is missing', async () => { + const enc = await renderEncryptedDoc(); + const notes = await writeAnnots([{ page: 1, type: 'text', rect: [0, 0, 1, 1] }]); + const err = await annotate( + parseArgs(['--input', enc, '--annotations', notes, '--output', tmpPath('x.pdf')]), + ).catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).exitCode).toBe(1); + expect((err as CliError).code).toBe(ErrorCode.PASSWORD); + }); + + it('fails with E_PASSWORD on a wrong password', async () => { + const enc = await renderEncryptedDoc(); + const notes = await writeAnnots([{ page: 1, type: 'text', rect: [0, 0, 1, 1] }]); + const err = await annotate( + parseArgs(['--input', enc, '--annotations', notes, '--output', tmpPath('x.pdf'), '--password', 'nope']), + ).catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).code).toBe(ErrorCode.PASSWORD); + }); + }); }); diff --git a/tests/commands/batch.test.ts b/tests/commands/batch.test.ts index 87511b2..a9e898f 100644 --- a/tests/commands/batch.test.ts +++ b/tests/commands/batch.test.ts @@ -2,10 +2,15 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import * as os from 'node:os'; import * as path from 'node:path'; import * as fs from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; import { batch } from '../../src/commands/batch.js'; import { parseArgs } from '../../src/utils/args.js'; import { CliError, ErrorCode } from '../../src/utils/error.js'; +const FIXTURES = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'fixtures'); +const RSA_KEY = path.join(FIXTURES, 'rsa-key.pem'); +const RSA_CERT = path.join(FIXTURES, 'rsa-cert.pem'); + function capture(fn: () => Promise): Promise { return new Promise((resolve, reject) => { const chunks: string[] = []; @@ -188,3 +193,319 @@ describe('batch', () => { }); }); }); + +interface TaskEntry { + id: string; + command: string; + ok: boolean; + output?: string; + error?: { code: string; message: string }; + skipped?: true; +} + +interface ManifestEnvelope { + ok: boolean; + command: string; + mode: string; + dryRun?: boolean; + total: number; + succeeded: number; + failed: number; + skipped: number; + tasks: TaskEntry[]; +} + +describe('batch --manifest', () => { + const dirs: string[] = []; + + afterEach(async () => { + for (const d of dirs.splice(0)) { + await fs.rm(d, { recursive: true, force: true }).catch(() => undefined); + } + delete process.env['PDFNATIVE_QUIET']; + }); + + const DOC_JSON = JSON.stringify({ blocks: [{ type: 'paragraph', text: 'pipeline' }] }); + + /** Write a manifest (and optional side files) into a fresh temp dir. */ + async function makeManifest( + manifest: unknown, + files: Record = {}, + ): Promise<{ dir: string; manifestPath: string }> { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'batch-manifest-')); + dirs.push(dir); + const manifestPath = path.join(dir, 'tasks.json'); + await fs.writeFile(manifestPath, JSON.stringify(manifest)); + for (const [name, content] of Object.entries(files)) { + await fs.writeFile(path.join(dir, name), content); + } + return { dir, manifestPath }; + } + + /** Run batch capturing stdout; resolves with output and the thrown error, if any. */ + async function runBatch(argv: string[]): Promise<{ out: string; error: unknown }> { + const chunks: string[] = []; + const spy = vi.spyOn(process.stdout, 'write').mockImplementation((c: unknown) => { + chunks.push(String(c)); + return true; + }); + let error: unknown; + try { + await batch(parseArgs(argv)); + } catch (e) { + error = e; + } finally { + spy.mockRestore(); + } + return { out: chunks.join(''), error }; + } + + it('runs a render → sign → encrypt pipeline via @refs and reports the envelope', async () => { + process.env['PDFNATIVE_QUIET'] = '1'; + const { dir, manifestPath } = await makeManifest({ + version: 1, + tasks: [ + { id: 'doc', command: 'render', flags: { input: 'doc.json', output: 'out/doc.pdf' } }, + { + id: 'signed', + command: 'sign', + flags: { input: '@doc', output: 'out/doc-signed.pdf', key: RSA_KEY, cert: RSA_CERT }, + }, + { + id: 'secured', + command: 'encrypt', + flags: { input: '@signed', output: 'out/doc-secured.pdf', 'owner-password': 'o-pass' }, + }, + ], + }, { 'doc.json': DOC_JSON }); + + const { out, error } = await runBatch(['--manifest', manifestPath, '--format', 'json']); + expect(error).toBeUndefined(); + + const envelope = JSON.parse(out) as ManifestEnvelope; + expect(envelope).toMatchObject({ + ok: true, command: 'batch', mode: 'manifest', + total: 3, succeeded: 3, failed: 0, skipped: 0, + }); + expect(envelope.tasks.map((t) => t.id)).toEqual(['doc', 'signed', 'secured']); + expect(envelope.tasks.every((t) => t.ok)).toBe(true); + + const secured = await fs.readFile(path.join(dir, 'out', 'doc-secured.pdf')); + expect(secured.subarray(0, 4).toString()).toBe('%PDF'); + expect(secured.toString('latin1')).toContain('/Encrypt'); + // The intermediate signed PDF was produced from the rendered @doc ref. + const signed = await fs.readFile(path.join(dir, 'out', 'doc-signed.pdf')); + expect(signed.subarray(0, 4).toString()).toBe('%PDF'); + }); + + it('rejects an unsupported manifest version with exit 2', async () => { + const { manifestPath } = await makeManifest({ + version: 2, + tasks: [{ id: 'a', command: 'render', flags: {} }], + }); + const { error } = await runBatch(['--manifest', manifestPath]); + expect(error).toBeInstanceOf(CliError); + expect(error).toMatchObject({ exitCode: 2 }); + }); + + it('rejects duplicate task ids with E_INPUT (exit 1)', async () => { + const { manifestPath } = await makeManifest({ + version: 1, + tasks: [ + { id: 'a', command: 'render', flags: { input: 'x.json', output: 'a.pdf' } }, + { id: 'a', command: 'render', flags: { input: 'x.json', output: 'b.pdf' } }, + ], + }); + const { error } = await runBatch(['--manifest', manifestPath]); + expect(error).toMatchObject({ exitCode: 1, code: ErrorCode.INPUT }); + }); + + it('rejects a command outside the whitelist with E_INPUT (exit 1)', async () => { + // ltv/compare are real commands but require positional arguments, which + // manifest tasks cannot carry yet — they are excluded from the whitelist. + for (const command of ['doctor', 'govern', 'not-a-command', 'ltv', 'compare']) { + const { manifestPath } = await makeManifest({ + version: 1, + tasks: [{ id: 'a', command, flags: {} }], + }); + const { error } = await runBatch(['--manifest', manifestPath]); + expect(error, `command=${command}`).toMatchObject({ exitCode: 1, code: ErrorCode.INPUT }); + } + }); + + it('rejects a forward @reference with E_INPUT (exit 1)', async () => { + const { manifestPath } = await makeManifest({ + version: 1, + tasks: [ + { id: 'first', command: 'sign', flags: { input: '@later', output: 'a.pdf', key: RSA_KEY, cert: RSA_CERT } }, + { id: 'later', command: 'render', flags: { input: 'x.json', output: 'b.pdf' } }, + ], + }); + const { error } = await runBatch(['--manifest', manifestPath]); + expect(error).toMatchObject({ exitCode: 1, code: ErrorCode.INPUT }); + }); + + it('rejects an unknown @reference with E_INPUT (exit 1)', async () => { + const { manifestPath } = await makeManifest({ + version: 1, + tasks: [{ id: 'a', command: 'render', flags: { input: '@ghost', output: 'a.pdf' } }], + }); + const { error } = await runBatch(['--manifest', manifestPath]); + expect(error).toMatchObject({ exitCode: 1, code: ErrorCode.INPUT }); + }); + + it('rejects an @reference to a task without an output with E_INPUT (exit 1)', async () => { + const { manifestPath } = await makeManifest({ + version: 1, + tasks: [ + { id: 'check', command: 'inspect', flags: { input: 'x.pdf' } }, + { id: 'b', command: 'render', flags: { input: '@check', output: 'b.pdf' } }, + ], + }); + const { error } = await runBatch(['--manifest', manifestPath]); + expect(error).toMatchObject({ exitCode: 1, code: ErrorCode.INPUT }); + }); + + it('rejects a relative path traversal in a manifest value (parity with direct flags)', async () => { + const { manifestPath } = await makeManifest({ + version: 1, + tasks: [{ id: 'a', command: 'render', flags: { input: 'x.json', output: '../../evil.pdf' } }], + }); + const { error } = await runBatch(['--manifest', manifestPath]); + expect(error).toBeInstanceOf(CliError); + expect((error as CliError).message).toContain('traversal'); + }); + + it('refuses a network-reaching task flag without --allow-network (exit 2)', async () => { + const { manifestPath } = await makeManifest({ + version: 1, + tasks: [{ + id: 'signed', + command: 'sign', + flags: { + input: 'a.pdf', output: 'b.pdf', key: RSA_KEY, cert: RSA_CERT, + timestamp: 'http://tsa.example/rfc3161', + }, + }], + }); + const { error } = await runBatch(['--manifest', manifestPath]); + expect(error).toBeInstanceOf(CliError); + expect(error).toMatchObject({ exitCode: 2, code: ErrorCode.USAGE }); + expect((error as CliError).message).toContain('--allow-network'); + }); + + it('accepts the same manifest with --allow-network (validated via --dry-run)', async () => { + process.env['PDFNATIVE_QUIET'] = '1'; + const { manifestPath } = await makeManifest({ + version: 1, + tasks: [{ + id: 'signed', + command: 'sign', + flags: { + input: 'a.pdf', output: 'b.pdf', key: RSA_KEY, cert: RSA_CERT, + timestamp: 'http://tsa.example/rfc3161', + }, + }], + }); + const { out, error } = await runBatch( + ['--manifest', manifestPath, '--allow-network', '--dry-run', '--format', 'json'], + ); + expect(error).toBeUndefined(); + const envelope = JSON.parse(out) as ManifestEnvelope; + expect(envelope).toMatchObject({ ok: true, mode: 'manifest', dryRun: true, total: 1 }); + }); + + it('fail-fast by default: tasks after a failure are skipped', async () => { + process.env['PDFNATIVE_QUIET'] = '1'; + const { dir, manifestPath } = await makeManifest({ + version: 1, + tasks: [ + { id: 'doc', command: 'render', flags: { input: 'doc.json', output: 'out/doc.pdf' } }, + { + id: 'signed', + command: 'sign', + flags: { input: '@doc', output: 'out/signed.pdf', key: 'no-such-key.pem', cert: RSA_CERT }, + }, + { + id: 'secured', + command: 'encrypt', + flags: { input: '@signed', output: 'out/secured.pdf', 'owner-password': 'x' }, + }, + ], + }, { 'doc.json': DOC_JSON }); + + const { out, error } = await runBatch(['--manifest', manifestPath, '--format', 'json']); + expect(error).toBeInstanceOf(CliError); + expect(error).toMatchObject({ exitCode: 1 }); + + const envelope = JSON.parse(out) as ManifestEnvelope; + expect(envelope).toMatchObject({ ok: false, total: 3, succeeded: 1, failed: 1, skipped: 1 }); + expect(envelope.tasks[1]?.error?.code).toBeDefined(); + expect(envelope.tasks[2]?.skipped).toBe(true); + await expect(fs.stat(path.join(dir, 'out', 'secured.pdf'))).rejects.toThrow(); + }); + + it('--continue-on-error runs independent tasks but skips @-dependents of a failure', async () => { + process.env['PDFNATIVE_QUIET'] = '1'; + const { dir, manifestPath } = await makeManifest({ + version: 1, + tasks: [ + { id: 'doc', command: 'render', flags: { input: 'doc.json', output: 'out/doc.pdf' } }, + { + id: 'signed', + command: 'sign', + flags: { input: '@doc', output: 'out/signed.pdf', key: 'no-such-key.pem', cert: RSA_CERT }, + }, + { + id: 'secured', + command: 'encrypt', + flags: { input: '@signed', output: 'out/secured.pdf', 'owner-password': 'x' }, + }, + { id: 'other', command: 'render', flags: { input: 'doc.json', output: 'out/other.pdf' } }, + ], + }, { 'doc.json': DOC_JSON }); + + const { out, error } = await runBatch( + ['--manifest', manifestPath, '--continue-on-error', '--format', 'json'], + ); + expect(error).toMatchObject({ exitCode: 1 }); + + const envelope = JSON.parse(out) as ManifestEnvelope; + expect(envelope).toMatchObject({ ok: false, total: 4, succeeded: 2, failed: 1, skipped: 1 }); + expect(envelope.tasks[2]?.skipped).toBe(true); + expect(envelope.tasks[3]?.ok).toBe(true); + const other = await fs.readFile(path.join(dir, 'out', 'other.pdf')); + expect(other.subarray(0, 4).toString()).toBe('%PDF'); + }); + + it('--dry-run validates and prints the plan without writing anything', async () => { + process.env['PDFNATIVE_QUIET'] = '1'; + const { dir, manifestPath } = await makeManifest({ + version: 1, + tasks: [ + { id: 'doc', command: 'render', flags: { input: 'doc.json', output: 'out/doc.pdf' } }, + { + id: 'secured', + command: 'encrypt', + flags: { input: '@doc', output: 'out/secured.pdf', 'owner-password': 'x' }, + }, + ], + }, { 'doc.json': DOC_JSON }); + + const { out, error } = await runBatch(['--manifest', manifestPath, '--dry-run', '--format', 'json']); + expect(error).toBeUndefined(); + const envelope = JSON.parse(out) as ManifestEnvelope; + expect(envelope).toMatchObject({ ok: true, dryRun: true, total: 2 }); + await expect(fs.stat(path.join(dir, 'out'))).rejects.toThrow(); + }); + + it('rejects --manifest combined with the directory mode (exit 2)', async () => { + const { dir, manifestPath } = await makeManifest({ + version: 1, + tasks: [{ id: 'a', command: 'render', flags: { input: 'x.json', output: 'a.pdf' } }], + }); + const { error } = await runBatch(['--manifest', manifestPath, '--input-dir', dir]); + expect(error).toBeInstanceOf(CliError); + expect(error).toMatchObject({ exitCode: 2, code: ErrorCode.USAGE }); + }); +}); diff --git a/tests/commands/compare.test.ts b/tests/commands/compare.test.ts new file mode 100644 index 0000000..c05af24 --- /dev/null +++ b/tests/commands/compare.test.ts @@ -0,0 +1,208 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as fs from 'node:fs/promises'; +import { render } from '../../src/commands/render.js'; +import { metadata } from '../../src/commands/metadata.js'; +import { compare } from '../../src/commands/compare.js'; +import { parseArgs } from '../../src/utils/args.js'; +import { CliError } from '../../src/utils/error.js'; + +const tmp: string[] = []; + +afterEach(async () => { + vi.restoreAllMocks(); + for (const f of tmp.splice(0)) { + await fs.rm(f, { recursive: true, force: true }).catch(() => undefined); + } +}); + +function tmpPath(name: string): string { + const p = path.join(os.tmpdir(), `cmp-${Date.now()}-${Math.random().toString(36).slice(2)}-${name}`); + tmp.push(p); + return p; +} + +async function renderDoc(blocks: readonly unknown[], extraArgs: readonly string[] = []): Promise { + const inPath = tmpPath('in.json'); + const outPath = tmpPath('doc.pdf'); + await fs.writeFile(inPath, JSON.stringify({ blocks }), 'utf8'); + await render(parseArgs(['--input', inPath, '--output', outPath, ...extraArgs])); + return outPath; +} + +const TWO_PAGES_A = [ + { type: 'heading', text: 'Report', level: 1 }, + { type: 'paragraph', text: 'first page body' }, + { type: 'pageBreak' }, + { type: 'paragraph', text: 'second page body' }, +]; + +const TWO_PAGES_B = [ + { type: 'heading', text: 'Report', level: 1 }, + { type: 'paragraph', text: 'first page body' }, + { type: 'pageBreak' }, + { type: 'paragraph', text: 'second page CHANGED' }, +]; + +async function captureStdout(fn: () => Promise): Promise<{ stdout: string; err: unknown }> { + const chunks: string[] = []; + const spy = vi.spyOn(process.stdout, 'write').mockImplementation((c: unknown) => { + chunks.push(String(c)); + return true; + }); + let err: unknown = undefined; + try { + await fn(); + } catch (e) { + err = e; + } finally { + spy.mockRestore(); + } + return { stdout: chunks.join(''), err }; +} + +interface CompareReport { + readonly equal: boolean; + readonly modes: readonly string[]; + readonly differences: readonly { + readonly kind: string; + readonly page?: number; + readonly path?: string; + readonly a?: unknown; + readonly b?: unknown; + readonly detail?: string; + }[]; +} + +async function compareJson(argv: readonly string[]): Promise<{ report: CompareReport; err: unknown }> { + const { stdout, err } = await captureStdout(() => compare(parseArgs([...argv, '--format', 'json']))); + return { report: JSON.parse(stdout) as CompareReport, err }; +} + +describe('compare', () => { + it('reports two identical renders as equal (exit 0)', async () => { + const a = await renderDoc(TWO_PAGES_A); + const b = await renderDoc(TWO_PAGES_A); + const { report, err } = await compareJson([a, b]); + expect(err).toBeUndefined(); + expect(report.equal).toBe(true); + expect(report.differences).toHaveLength(0); + expect(report.modes).toEqual(['structure', 'text']); + }); + + it('detects a text difference on the right page (E_CHECK_FAILED)', async () => { + const a = await renderDoc(TWO_PAGES_A); + const b = await renderDoc(TWO_PAGES_B); + const { report, err } = await compareJson([a, b]); + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).code).toBe('E_CHECK_FAILED'); + expect((err as CliError).exitCode).toBe(1); + expect(report.equal).toBe(false); + const textDiff = report.differences.find((d) => d.kind === 'text'); + expect(textDiff).toBeDefined(); + expect(textDiff?.page).toBe(2); + }); + + it('detects a metadata difference', async () => { + const a = await renderDoc(TWO_PAGES_A); + const base = await renderDoc(TWO_PAGES_A); + const b = tmpPath('retitled.pdf'); + await metadata(parseArgs([ + '--input', base, '--output', b, + '--title', 'Divergent', '--mod-date', '2026-01-15T00:00:00Z', + ])); + const { report, err } = await compareJson([a, b]); + expect(err).toBeInstanceOf(CliError); + expect(report.differences.some((d) => d.kind === 'metadata' && d.path === 'Title')).toBe(true); + }); + + it('detects a page-count difference', async () => { + const a = await renderDoc([{ type: 'paragraph', text: 'only page' }]); + const b = await renderDoc([ + { type: 'paragraph', text: 'only page' }, + { type: 'pageBreak' }, + { type: 'paragraph', text: 'extra page' }, + ]); + const { report, err } = await compareJson([a, b]); + expect(err).toBeInstanceOf(CliError); + const diff = report.differences.find((d) => d.kind === 'pageCount'); + expect(diff).toBeDefined(); + expect(diff?.a).toBe(1); + expect(diff?.b).toBe(2); + }); + + it('--mode text ignores metadata differences', async () => { + const a = await renderDoc(TWO_PAGES_A); + const base = await renderDoc(TWO_PAGES_A); + const b = tmpPath('retitled.pdf'); + await metadata(parseArgs([ + '--input', base, '--output', b, + '--title', 'Divergent', '--mod-date', '2026-01-15T00:00:00Z', + ])); + const { report, err } = await compareJson([a, b, '--mode', 'text']); + expect(err).toBeUndefined(); + expect(report.equal).toBe(true); + expect(report.modes).toEqual(['text']); + }); + + it('--tolerance absorbs a sub-threshold page-size delta', async () => { + const a = await renderDoc([{ type: 'paragraph', text: 'sized' }], ['--page-size', '600x800']); + const b = await renderDoc([{ type: 'paragraph', text: 'sized' }], ['--page-size', '600.3x800']); + + const strict = await compareJson([a, b, '--mode', 'structure']); + expect(strict.err).toBeInstanceOf(CliError); + expect(strict.report.differences.some((d) => d.kind === 'pageSize')).toBe(true); + + const tolerant = await compareJson([a, b, '--mode', 'structure', '--tolerance', '0.5']); + expect(tolerant.err).toBeUndefined(); + expect(tolerant.report.equal).toBe(true); + }); + + it('--ignore-whitespace normalizes layout-only differences', async () => { + const a = await renderDoc([{ type: 'paragraph', text: 'alpha beta' }]); + const b = await renderDoc([ + { type: 'paragraph', text: 'alpha' }, + { type: 'paragraph', text: 'beta' }, + ]); + + const strict = await compareJson([a, b, '--mode', 'text']); + expect(strict.err).toBeInstanceOf(CliError); + + const relaxed = await compareJson([a, b, '--mode', 'text', '--ignore-whitespace']); + expect(relaxed.err).toBeUndefined(); + expect(relaxed.report.equal).toBe(true); + }); + + it('honours --pages for the text diff', async () => { + const a = await renderDoc(TWO_PAGES_A); + const b = await renderDoc(TWO_PAGES_B); // only page 2 differs + const { report, err } = await compareJson([a, b, '--mode', 'text', '--pages', '1']); + expect(err).toBeUndefined(); + expect(report.equal).toBe(true); + }); + + it('reports a missing file as E_IO', async () => { + const a = await renderDoc(TWO_PAGES_A); + const err = await compare(parseArgs([a, tmpPath('does-not-exist.pdf')])).catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).code).toBe('E_IO'); + expect((err as CliError).exitCode).toBe(1); + }); + + it('requires exactly two positionals (exit 2)', async () => { + const a = await renderDoc(TWO_PAGES_A); + const err = await compare(parseArgs([a])).catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).exitCode).toBe(2); + }); + + it('emits a human-readable text report', async () => { + const a = await renderDoc(TWO_PAGES_A); + const b = await renderDoc(TWO_PAGES_B); + const { stdout, err } = await captureStdout(() => compare(parseArgs([a, b]))); + expect(err).toBeInstanceOf(CliError); + expect(stdout).toContain('differences ('); + expect(stdout).toContain('[text] page 2'); + }); +}); diff --git a/tests/commands/doctor.test.ts b/tests/commands/doctor.test.ts index 1c0cb7e..d1d192c 100644 --- a/tests/commands/doctor.test.ts +++ b/tests/commands/doctor.test.ts @@ -40,7 +40,7 @@ describe('doctor', () => { expect(names).toEqual(expect.arrayContaining(['cli', 'node', 'webcrypto', 'pdfnative', 'commands'])); }); - it('passes in this environment (Node >= 20, Web Crypto present) and leaves exit code 0', async () => { + it('passes in this environment (Node >= 22, Web Crypto present) and leaves exit code 0', async () => { const out = capture(); await doctor(parseArgs(['--format', 'json'])); out.restore(); diff --git a/tests/commands/inspect.test.ts b/tests/commands/inspect.test.ts index dce876b..429b0a9 100644 --- a/tests/commands/inspect.test.ts +++ b/tests/commands/inspect.test.ts @@ -2,11 +2,17 @@ import { describe, it, expect, afterEach } from 'vitest'; import * as os from 'node:os'; import * as path from 'node:path'; import * as fs from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; import { inspect } from '../../src/commands/inspect.js'; import { render } from '../../src/commands/render.js'; +import { sign } from '../../src/commands/sign.js'; import { parseArgs } from '../../src/utils/args.js'; import { CliError, ErrorCode } from '../../src/utils/error.js'; +const FIXTURES = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'fixtures'); +const RSA_KEY = path.join(FIXTURES, 'rsa-key.pem'); +const RSA_CERT = path.join(FIXTURES, 'rsa-cert.pem'); + const minimalParams = JSON.stringify({ title: 'Inspect Test', blocks: [{ type: 'paragraph', text: 'Hello world' }], @@ -471,4 +477,186 @@ describe('inspect', () => { expect(JSON.parse(out)).toEqual({ pages: expect.any(Number) }); }); }); + + // ────────────────────────────────────────────────────────────────── + // v1.4.0 — --signatures, page boxes/userUnit, metadata.trapped + // ────────────────────────────────────────────────────────────────── + + describe('v1.4.0 enrichments', () => { + async function inspectJson(argv: readonly string[]): Promise> { + const chunks: string[] = []; + const original = process.stdout.write.bind(process.stdout); + process.stdout.write = (c: unknown) => { + chunks.push(String(c)); + return true; + }; + try { + await inspect(parseArgs([...argv])); + } finally { + process.stdout.write = original; + } + return JSON.parse(chunks.join('')) as Record; + } + + async function renderWith(params: unknown, extraFlags: readonly string[] = []): Promise { + const stamp = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + const inputPath = path.join(os.tmpdir(), `inspect14-in-${stamp}.json`); + const outputPath = path.join(os.tmpdir(), `inspect14-out-${stamp}.pdf`); + tmpFiles.push(inputPath, outputPath); + await fs.writeFile(inputPath, JSON.stringify(params), 'utf8'); + await render(parseArgs(['--input', inputPath, '--output', outputPath, ...extraFlags])); + return outputPath; + } + + async function writeLayout(layout: unknown): Promise { + const layoutPath = path.join(os.tmpdir(), `inspect14-layout-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); + tmpFiles.push(layoutPath); + await fs.writeFile(layoutPath, JSON.stringify(layout), 'utf8'); + return layoutPath; + } + + async function signedPdf(): Promise { + const src = await generateTestPdf(); + const out = path.join(os.tmpdir(), `inspect14-signed-${Date.now()}-${Math.random().toString(36).slice(2)}.pdf`); + tmpFiles.push(out); + await sign(parseArgs([ + '--input', src, + '--output', out, + '--key', RSA_KEY, + '--cert', RSA_CERT, + '--algorithm', 'rsa-sha256', + ])); + return out; + } + + it('--signatures on an unsigned PDF emits signatures: []', async () => { + const pdfPath = await generateTestPdf(); + const result = await inspectJson(['--input', pdfPath, '--signatures']); + expect(result['signatures']).toEqual([]); + }); + + it('without --signatures the signatures field stays a number (no shape change)', async () => { + const pdfPath = await generateTestPdf(); + const result = await inspectJson(['--input', pdfPath]); + expect(typeof result['signatures']).toBe('number'); + expect(Array.isArray(result['signatures'])).toBe(false); + }); + + it('--signatures on a signed PDF lists one entry with subFilter/byteRange (no contents bytes)', async () => { + const signedPath = await signedPdf(); + const result = await inspectJson(['--input', signedPath, '--signatures']); + const sigs = result['signatures'] as Array>; + expect(Array.isArray(sigs)).toBe(true); + expect(sigs).toHaveLength(1); + const s = sigs[0] as Record; + expect(s['subFilter']).toBe('adbe.pkcs7.detached'); + const byteRange = s['byteRange'] as number[]; + expect(byteRange).toHaveLength(4); + expect(byteRange[0]).toBe(0); + expect(byteRange[1]).toBeGreaterThan(0); + expect(s['isPlaceholder']).toBe(false); + expect(s['isDocTimestamp']).toBe(false); + expect(typeof s['sigObjNum']).toBe('number'); + expect(s['contentsLength']).toBeGreaterThan(0); + expect('contents' in s).toBe(false); + }); + + it('--check signed passes on a signed PDF and fails on an unsigned one', async () => { + const silence = process.stdout.write.bind(process.stdout); + process.stdout.write = () => true; + const origStderr = process.stderr.write.bind(process.stderr); + process.stderr.write = () => true; + try { + // Signed → no throw. + const signedPath = await signedPdf(); + await inspect(parseArgs(['--input', signedPath, '--check', 'signed'])); + // Unsigned → E_CHECK_FAILED. + const plain = await generateTestPdf(); + const err = await inspect(parseArgs(['--input', plain, '--check', 'signed'])) + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).code).toBe(ErrorCode.CHECK_FAILED); + } finally { + process.stdout.write = silence; + process.stderr.write = origStderr; + } + }); + + it('--check "signatures>=N" counts non-placeholder signatures', async () => { + const signedPath = await signedPdf(); + const silence = process.stdout.write.bind(process.stdout); + process.stdout.write = () => true; + const origStderr = process.stderr.write.bind(process.stderr); + process.stderr.write = () => true; + try { + // >=1 passes on a singly-signed doc. + await inspect(parseArgs(['--input', signedPath, '--check', 'signatures>=1'])); + // >=2 fails with the stable check code. + const err = await inspect(parseArgs(['--input', signedPath, '--check', 'signatures>=2'])) + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).exitCode).toBe(1); + expect((err as CliError).code).toBe(ErrorCode.CHECK_FAILED); + } finally { + process.stdout.write = silence; + process.stderr.write = origStderr; + } + }); + + it('--pages reports trimBox/bleedBox from a print.bleed render', async () => { + const layoutPath = await writeLayout({ print: { bleed: 8.5 } }); + const pdfPath = await renderWith( + { title: 'Bleed', blocks: [{ type: 'paragraph', text: 'x' }] }, + ['--layout', layoutPath], + ); + const result = await inspectJson(['--input', pdfPath, '--pages']); + const pages = result['pages'] as Array>; + expect(pages.length).toBeGreaterThanOrEqual(1); + const p = pages[0] as Record; + const w = p['width'] as number; + const h = p['height'] as number; + const trim = p['trimBox'] as number[]; + expect(trim).toHaveLength(4); + expect(trim[0]).toBeCloseTo(8.5, 2); + expect(trim[1]).toBeCloseTo(8.5, 2); + expect(trim[2]).toBeCloseTo(w - 8.5, 2); + expect(trim[3]).toBeCloseTo(h - 8.5, 2); + expect(p['bleedBox']).toEqual([0, 0, w, h]); + }); + + it('--pages omits box keys when the page has none', async () => { + const pdfPath = await generateTestPdf(); + const result = await inspectJson(['--input', pdfPath, '--pages']); + const p = (result['pages'] as Array>)[0] as Record; + expect('trimBox' in p).toBe(false); + expect('bleedBox' in p).toBe(false); + expect('artBox' in p).toBe(false); + expect('userUnit' in p).toBe(false); + }); + + it('--pages reports userUnit from a print.userUnit render', async () => { + const layoutPath = await writeLayout({ print: { userUnit: 2 } }); + const pdfPath = await renderWith( + { title: 'Big', blocks: [{ type: 'paragraph', text: 'x' }] }, + ['--layout', layoutPath], + ); + const result = await inspectJson(['--input', pdfPath, '--pages']); + const p = (result['pages'] as Array>)[0] as Record; + expect(p['userUnit']).toBe(2); + }); + + it('metadata.trapped surfaces /Info /Trapped and is omitted otherwise', async () => { + const trappedPdf = await renderWith({ + title: 'Trapped', + metadata: { trapped: 'True' }, + blocks: [{ type: 'paragraph', text: 'x' }], + }); + const withTrapped = await inspectJson(['--input', trappedPdf]); + expect((withTrapped['metadata'] as Record)['trapped']).toBe('True'); + + const plainPdf = await generateTestPdf(); + const without = await inspectJson(['--input', plainPdf]); + expect('trapped' in (without['metadata'] as Record)).toBe(false); + }); + }); }); diff --git a/tests/commands/ltv.test.ts b/tests/commands/ltv.test.ts new file mode 100644 index 0000000..0989ec0 --- /dev/null +++ b/tests/commands/ltv.test.ts @@ -0,0 +1,291 @@ +// `ltv` (PAdES B-LT) + `doc-timestamp` (PAdES B-LTA) — offline tests. +// +// A PDF is rendered and signed with the mock-PKI signer certificate (its +// AIA/CRL-DP extensions point at http://mock.invalid/... — the committed PEM +// fixtures carry none, and the LTV collector only queries certificates that +// advertise a revocation source). All OCSP/CRL/TSA round-trips are served by +// the in-process mock providers — ZERO network. + +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as fs from 'node:fs/promises'; +import { render } from '../../src/commands/render.js'; +import { sign } from '../../src/commands/sign.js'; +import { ltv } from '../../src/commands/ltv.js'; +import { docTimestamp } from '../../src/commands/docTimestamp.js'; +import { parseArgs } from '../../src/utils/args.js'; +import { CliError, ErrorCode } from '../../src/utils/error.js'; +import { + setRevocationProvider, + setTimestampProvider, + listSignatures, + openPdf, + derSequence, + derInteger, +} from '../../src/core-bridge/index.js'; +import type { RsaPrivateKey } from '../../src/core-bridge/index.js'; +import { + createMockPki, + createMockRevocationProvider, + createMockTimestampProvider, + toPem, +} from '../helpers/mock-pki.js'; + +// ── Fixtures ───────────────────────────────────────────────────────── + +/** PKCS#1 RSAPrivateKey DER (RFC 8017 A.1.2) for the mock signer key. */ +function rsaPrivateKeyPkcs1Der(key: RsaPrivateKey): Uint8Array { + return derSequence( + derInteger(0n), + derInteger(key.n), + derInteger(65537n), + derInteger(key.d), + derInteger(key.p), + derInteger(key.q), + derInteger(key.dp), + derInteger(key.dq), + derInteger(key.qi), + ); +} + +let dir: string; +let signedPdf: string; + +beforeAll(async () => { + const pki = createMockPki(); + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ltv-test-')); + + // PEM credentials from the mock signer (AIA/CRL URLs included). + const keyPath = path.join(dir, 'signer-key.pem'); + const certPath = path.join(dir, 'signer-cert.pem'); + const rootPath = path.join(dir, 'root-cert.pem'); + await fs.writeFile(keyPath, toPem('RSA PRIVATE KEY', rsaPrivateKeyPkcs1Der(pki.signerKey)), 'utf8'); + await fs.writeFile(certPath, toPem('CERTIFICATE', pki.signerCert.raw), 'utf8'); + await fs.writeFile(rootPath, toPem('CERTIFICATE', pki.rootCert.raw), 'utf8'); + + // Render → sign (the CMS embeds signer + root so the collector can build + // the chain and the OCSP CertID). + const paramsPath = path.join(dir, 'doc.json'); + const docPath = path.join(dir, 'doc.pdf'); + signedPdf = path.join(dir, 'signed.pdf'); + await fs.writeFile( + paramsPath, + JSON.stringify({ title: 'LTV Test', blocks: [{ type: 'paragraph', text: 'long-term validation' }] }), + 'utf8', + ); + await render(parseArgs(['--input', paramsPath, '--output', docPath])); + await sign(parseArgs([ + '--input', docPath, '--output', signedPdf, + '--key', keyPath, '--cert', certPath, '--cert-chain', rootPath, + ])); +}, 60000); + +afterAll(async () => { + await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined); +}); + +beforeEach(() => { + setRevocationProvider(createMockRevocationProvider(createMockPki())); +}); + +afterEach(() => { + setRevocationProvider(null); + setTimestampProvider(null); + vi.restoreAllMocks(); +}); + +function out(name: string): string { + return path.join(dir, `${Date.now()}-${Math.random().toString(36).slice(2)}-${name}`); +} + +async function fileExists(p: string): Promise { + return fs.access(p).then(() => true, () => false); +} + +interface LtvDataJson { + readonly version: number; + readonly certificates: readonly string[]; + readonly ocspResponses: readonly string[]; + readonly crls: readonly string[]; + readonly vri: readonly { readonly key: string; readonly certs: readonly number[]; readonly ocsps: readonly number[]; readonly crls: readonly number[] }[]; +} + +async function collectToFile(extra: readonly string[] = []): Promise<{ readonly jsonPath: string; readonly data: LtvDataJson }> { + const jsonPath = out('ltv.json'); + await ltv(parseArgs(['collect', '--online', '--input', signedPdf, '--output', jsonPath, ...extra])); + const data = JSON.parse(await fs.readFile(jsonPath, 'utf8')) as LtvDataJson; + return { jsonPath, data }; +} + +// ── ltv collect ────────────────────────────────────────────────────── + +describe('ltv collect', () => { + it('collects a version-1 ltv-data document with certificates and OCSP responses (prefer ocsp)', async () => { + const { data } = await collectToFile(['--prefer', 'ocsp']); + expect(data.version).toBe(1); + expect(data.certificates.length).toBeGreaterThanOrEqual(1); + expect(data.ocspResponses.length).toBeGreaterThanOrEqual(1); + expect(data.vri.length).toBeGreaterThanOrEqual(1); + // Every payload is valid base64 and the /VRI key is uppercase-hex SHA-1. + for (const b64 of [...data.certificates, ...data.ocspResponses, ...data.crls]) { + expect(Buffer.from(b64, 'base64').length).toBeGreaterThan(0); + } + expect(data.vri[0]?.key).toMatch(/^[0-9A-F]{40}$/); + }); + + it('refuses to run without --online and points at the offline `ltv embed` path', async () => { + const err = await ltv(parseArgs(['collect', '--input', signedPdf, '--output', out('x.json')])) + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).exitCode).toBe(2); + expect((err as CliError).message).toContain('ltv embed'); + }); + + it('still collects (and archives) responses for a REVOKED certificate — LTV records state, it does not judge it', async () => { + // B-LT archives the revocation evidence as returned: a "revoked" + // OCSP response is exactly what a later validator needs to prove the + // certificate's status at archival time. + setRevocationProvider(createMockRevocationProvider(createMockPki(), { revoked: true })); + const { data } = await collectToFile(); + expect(data.version).toBe(1); + expect(data.ocspResponses.length).toBeGreaterThanOrEqual(1); + }); + + it('rejects an invalid --prefer value', async () => { + await expect( + ltv(parseArgs(['collect', '--online', '--input', signedPdf, '--prefer', 'dns'])), + ).rejects.toMatchObject({ exitCode: 2 }); + }); + + it('rejects an unknown subcommand and a missing subcommand', async () => { + await expect(ltv(parseArgs(['harvest']))).rejects.toMatchObject({ exitCode: 2 }); + await expect(ltv(parseArgs([]))).rejects.toMatchObject({ exitCode: 2 }); + }); +}); + +// ── ltv embed ──────────────────────────────────────────────────────── + +describe('ltv embed', () => { + it('round-trips: collect → embed writes a /DSS dictionary into the PDF (fully offline)', async () => { + const { jsonPath } = await collectToFile(); + // The embed phase must not need any provider — prove it. + setRevocationProvider(null); + + const outPdf = out('ltv.pdf'); + await ltv(parseArgs(['embed', '--input', signedPdf, '--data', jsonPath, '--output', outPdf])); + + const bytes = await fs.readFile(outPdf); + expect(bytes.toString('latin1')).toContain('/DSS'); + // Structural check through the library's own reader. + const reader = openPdf(new Uint8Array(bytes)); + expect(reader.getCatalog().has('DSS')).toBe(true); + }); + + it('requires --data', async () => { + await expect( + ltv(parseArgs(['embed', '--input', signedPdf, '--output', out('x.pdf')])), + ).rejects.toMatchObject({ exitCode: 2 }); + }); + + it('rejects an unsupported ltv-data version with E_INPUT', async () => { + const { jsonPath, data } = await collectToFile(); + await fs.writeFile(jsonPath, JSON.stringify({ ...data, version: 2 }), 'utf8'); + await expect( + ltv(parseArgs(['embed', '--input', signedPdf, '--data', jsonPath, '--output', out('x.pdf')])), + ).rejects.toMatchObject({ code: ErrorCode.INPUT }); + }); + + it('rejects invalid base64 payloads with E_PARSE', async () => { + const { jsonPath, data } = await collectToFile(); + await fs.writeFile(jsonPath, JSON.stringify({ ...data, certificates: ['%%not-base64%%'] }), 'utf8'); + await expect( + ltv(parseArgs(['embed', '--input', signedPdf, '--data', jsonPath, '--output', out('x.pdf')])), + ).rejects.toMatchObject({ code: ErrorCode.PARSE }); + }); + + it('rejects a non-JSON data file with E_PARSE', async () => { + const jsonPath = out('bad.json'); + await fs.writeFile(jsonPath, 'not json at all {', 'utf8'); + await expect( + ltv(parseArgs(['embed', '--input', signedPdf, '--data', jsonPath, '--output', out('x.pdf')])), + ).rejects.toMatchObject({ code: ErrorCode.PARSE }); + }); + + it('--dry-run validates the data but writes nothing', async () => { + const { jsonPath } = await collectToFile(); + const outPdf = out('never.pdf'); + await ltv(parseArgs(['embed', '--input', signedPdf, '--data', jsonPath, '--output', outPdf, '--dry-run'])); + expect(await fileExists(outPdf)).toBe(false); + }); +}); + +// ── ltv add ────────────────────────────────────────────────────────── + +describe('ltv add', () => { + it('one-pass collect+embed produces a /DSS-equipped PDF', async () => { + const outPdf = out('added.pdf'); + await ltv(parseArgs(['add', '--online', '--input', signedPdf, '--output', outPdf])); + const bytes = await fs.readFile(outPdf); + expect(bytes.toString('latin1')).toContain('/DSS'); + expect(openPdf(new Uint8Array(bytes)).getCatalog().has('DSS')).toBe(true); + }); + + it('refuses to run without --online and points at the offline `ltv embed` path', async () => { + const err = await ltv(parseArgs(['add', '--input', signedPdf, '--output', out('x.pdf')])) + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).exitCode).toBe(2); + expect((err as CliError).message).toContain('ltv embed'); + }); +}); + +// ── doc-timestamp ──────────────────────────────────────────────────── + +describe('doc-timestamp', () => { + it('appends a /DocTimeStamp signature entry (mock TSA, zero network)', async () => { + setTimestampProvider(createMockTimestampProvider(createMockPki())); + const outPdf = out('lta.pdf'); + await docTimestamp(parseArgs([ + '--input', signedPdf, '--output', outPdf, '--url', 'http://tsa.mock.invalid/tsr', + ])); + const sigs = listSignatures(new Uint8Array(await fs.readFile(outPdf))); + expect(sigs.some((s) => s.isDocTimestamp)).toBe(true); + // The original signature is still listed alongside the timestamp. + expect(sigs.some((s) => !s.isDocTimestamp)).toBe(true); + }); + + it('requires --url (explicit network opt-in)', async () => { + const err = await docTimestamp(parseArgs(['--input', signedPdf, '--output', out('x.pdf')])) + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).exitCode).toBe(2); + }); + + it('--dry-run never calls the provider and writes nothing', async () => { + const getTimestamp = vi.fn(() => Promise.reject(new Error('network attempted'))); + setTimestampProvider({ getTimestamp }); + const outPdf = out('never.pdf'); + await docTimestamp(parseArgs([ + '--input', signedPdf, '--output', outPdf, '--url', 'http://tsa.mock.invalid/tsr', '--dry-run', + ])); + expect(getTimestamp).not.toHaveBeenCalled(); + expect(await fileExists(outPdf)).toBe(false); + }); + + it('rejects an invalid --digest and a non-http(s) --url', async () => { + await expect( + docTimestamp(parseArgs(['--input', signedPdf, '--url', 'http://tsa.mock.invalid/tsr', '--digest', 'md5'])), + ).rejects.toMatchObject({ exitCode: 2 }); + await expect( + docTimestamp(parseArgs(['--input', signedPdf, '--url', 'ftp://tsa.mock.invalid/tsr'])), + ).rejects.toMatchObject({ exitCode: 2 }); + }); + + it('surfaces a malformed TSA response as E_PARSE (generic message)', async () => { + setTimestampProvider({ getTimestamp: () => Promise.resolve(new Uint8Array([0x00, 0x01, 0x02])) }); + await expect( + docTimestamp(parseArgs(['--input', signedPdf, '--output', out('x.pdf'), '--url', 'http://tsa.mock.invalid/tsr'])), + ).rejects.toMatchObject({ code: ErrorCode.PARSE }); + }); +}); diff --git a/tests/commands/metadata.test.ts b/tests/commands/metadata.test.ts new file mode 100644 index 0000000..8787ccd --- /dev/null +++ b/tests/commands/metadata.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as fs from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { render } from '../../src/commands/render.js'; +import { sign } from '../../src/commands/sign.js'; +import { metadata } from '../../src/commands/metadata.js'; +import { inspect } from '../../src/commands/inspect.js'; +import { extractTextCmd } from '../../src/commands/extract-text.js'; +import { parseArgs } from '../../src/utils/args.js'; +import { CliError } from '../../src/utils/error.js'; + +const FIXTURES = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'fixtures'); +const RSA_KEY = path.join(FIXTURES, 'rsa-key.pem'); +const RSA_CERT = path.join(FIXTURES, 'rsa-cert.pem'); + +const FIXED_DATE = '2026-01-15T00:00:00Z'; + +const tmp: string[] = []; + +afterEach(async () => { + vi.restoreAllMocks(); + for (const f of tmp.splice(0)) { + await fs.rm(f, { recursive: true, force: true }).catch(() => undefined); + } +}); + +function tmpPath(name: string): string { + const p = path.join(os.tmpdir(), `meta-${Date.now()}-${Math.random().toString(36).slice(2)}-${name}`); + tmp.push(p); + return p; +} + +async function renderDoc(): Promise { + const inPath = tmpPath('in.json'); + const outPath = tmpPath('doc.pdf'); + await fs.writeFile(inPath, JSON.stringify({ + blocks: [ + { type: 'heading', text: 'Metadata Doc', level: 1 }, + { type: 'paragraph', text: 'stable body text' }, + ], + }), 'utf8'); + await render(parseArgs(['--input', inPath, '--output', outPath])); + return outPath; +} + +async function captureStdout(fn: () => Promise): Promise { + const chunks: string[] = []; + const spy = vi.spyOn(process.stdout, 'write').mockImplementation((c: unknown) => { + chunks.push(String(c)); + return true; + }); + try { + await fn(); + } finally { + spy.mockRestore(); + } + return chunks.join(''); +} + +interface InspectJson { + readonly pageCount: number; + readonly metadata: { + readonly title: string | null; + readonly author: string | null; + readonly subject: string | null; + }; +} + +async function inspectJson(pdfPath: string): Promise { + const out = await captureStdout(() => inspect(parseArgs(['--input', pdfPath, '--format', 'json']))); + return JSON.parse(out) as InspectJson; +} + +async function extractedText(pdfPath: string): Promise { + return captureStdout(() => extractTextCmd(parseArgs(['--input', pdfPath]))); +} + +describe('metadata', () => { + it('updates title and author without touching the document content', async () => { + const doc = await renderDoc(); + const before = await inspectJson(doc); + const textBefore = await extractedText(doc); + const out = tmpPath('updated.pdf'); + + await metadata(parseArgs([ + '--input', doc, '--output', out, + '--title', 'New Title', '--author', 'New Author', + '--mod-date', FIXED_DATE, + ])); + + const after = await inspectJson(out); + expect(after.metadata.title).toBe('New Title'); + expect(after.metadata.author).toBe('New Author'); + expect(after.pageCount).toBe(before.pageCount); + expect(await extractedText(out)).toBe(textBefore); + }); + + it('preserves an existing signature (original bytes are a prefix of the output)', async () => { + const doc = await renderDoc(); + const signed = tmpPath('signed.pdf'); + await sign(parseArgs([ + '--input', doc, '--output', signed, + '--key', RSA_KEY, '--cert', RSA_CERT, + ])); + const out = tmpPath('signed-meta.pdf'); + + await metadata(parseArgs([ + '--input', signed, '--output', out, + '--title', 'Retitled', '--mod-date', FIXED_DATE, + ])); + + const original = await fs.readFile(signed); + const updated = await fs.readFile(out); + // Incremental save: the document only GROWS — the signed revision + // (signature bytes included) must be an exact byte prefix. + expect(updated.length).toBeGreaterThan(original.length); + expect(updated.subarray(0, original.length).equals(original)).toBe(true); + }); + + it('accepts a --from-json payload', async () => { + const doc = await renderDoc(); + const payload = tmpPath('meta.json'); + await fs.writeFile(payload, JSON.stringify({ + title: 'From JSON', + keywords: 'alpha, beta', + modDate: FIXED_DATE, + }), 'utf8'); + const out = tmpPath('fromjson.pdf'); + + await metadata(parseArgs(['--input', doc, '--output', out, '--from-json', payload])); + + const after = await inspectJson(out); + expect(after.metadata.title).toBe('From JSON'); + const original = await fs.readFile(doc); + const updated = await fs.readFile(out); + const tail = updated.subarray(original.length).toString('latin1'); + expect(tail).toContain('/Keywords'); + }); + + it('rejects an unknown key in --from-json with E_INPUT', async () => { + const doc = await renderDoc(); + const payload = tmpPath('bad-meta.json'); + await fs.writeFile(payload, JSON.stringify({ title: 'ok', creator: 'nope' }), 'utf8'); + + const err = await metadata(parseArgs([ + '--input', doc, '--output', tmpPath('x.pdf'), '--from-json', payload, + ])).catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).code).toBe('E_INPUT'); + }); + + it('rejects --from-json combined with per-field flags (exit 2)', async () => { + const doc = await renderDoc(); + const payload = tmpPath('meta.json'); + await fs.writeFile(payload, JSON.stringify({ title: 'x' }), 'utf8'); + + const err = await metadata(parseArgs([ + '--input', doc, '--output', tmpPath('x.pdf'), + '--from-json', payload, '--title', 'clash', + ])).catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).exitCode).toBe(2); + }); + + it('requires at least one field (exit 2)', async () => { + const doc = await renderDoc(); + const err = await metadata(parseArgs([ + '--input', doc, '--output', tmpPath('x.pdf'), + ])).catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).exitCode).toBe(2); + }); + + it('--dry-run validates without writing', async () => { + const doc = await renderDoc(); + const out = tmpPath('nope.pdf'); + await metadata(parseArgs([ + '--input', doc, '--output', out, '--title', 'Dry', '--dry-run', + ])); + await expect(fs.access(out)).rejects.toThrow(); + }); + + it('stamps a /ModDate by default when --mod-date is omitted', async () => { + const doc = await renderDoc(); + const out = tmpPath('moddate.pdf'); + await metadata(parseArgs(['--input', doc, '--output', out, '--title', 'Dated'])); + + const original = await fs.readFile(doc); + const updated = await fs.readFile(out); + const tail = updated.subarray(original.length).toString('latin1'); + expect(tail).toContain('/ModDate'); + }); +}); diff --git a/tests/commands/pagetree.test.ts b/tests/commands/pagetree.test.ts index 3d89a90..1576773 100644 --- a/tests/commands/pagetree.test.ts +++ b/tests/commands/pagetree.test.ts @@ -131,6 +131,110 @@ describe('extract', () => { }); }); +// ────────────────────────────────────────────────────────────────── +// v1.4.0 — print-production boxes survive page-tree rebuilds +// (pdfnative 1.7.0 copies /TrimBox /BleedBox /ArtBox /UserUnit — this +// is the non-regression suite for the dependency bump). +// ────────────────────────────────────────────────────────────────── + +/** Render a PDF whose pages carry print-production geometry (`layout.print`). */ +async function renderPrintPages(pages: number, print: Record): Promise { + const blocks: unknown[] = [{ type: 'heading', text: 'T', level: 1 }]; + for (let i = 0; i < pages; i++) { + if (i > 0) blocks.push({ type: 'pageBreak' }); + blocks.push({ type: 'paragraph', text: `page ${i + 1}` }); + } + const inPath = tmpPath('print-in.json'); + const layoutPath = tmpPath('print-layout.json'); + const outPath = tmpPath('print-doc.pdf'); + await fs.writeFile(inPath, JSON.stringify({ blocks }), 'utf8'); + await fs.writeFile(layoutPath, JSON.stringify({ print }), 'utf8'); + await render(parseArgs(['--input', inPath, '--output', outPath, '--layout', layoutPath])); + return outPath; +} + +/** `inspect --pages` page entries (boxes + userUnit) for a PDF. */ +async function inspectPages(pdfPath: string): Promise>> { + const chunks: string[] = []; + vi.spyOn(process.stdout, 'write').mockImplementation((c: unknown) => { chunks.push(String(c)); return true; }); + await inspect(parseArgs(['--input', pdfPath, '--format', 'json', '--pages'])); + return JSON.parse(chunks.join('')).pages as Array>; +} + +/** Assert a page entry carries a TrimBox = MediaBox inset by `bleed` on every side. */ +function expectTrimFromBleed(p: Record, bleed: number): void { + const w = p['width'] as number; + const h = p['height'] as number; + const trim = p['trimBox'] as number[]; + expect(Array.isArray(trim)).toBe(true); + expect(trim).toHaveLength(4); + expect(trim[0]).toBeCloseTo(bleed, 2); + expect(trim[1]).toBeCloseTo(bleed, 2); + expect(trim[2]).toBeCloseTo(w - bleed, 2); + expect(trim[3]).toBeCloseTo(h - bleed, 2); +} + +describe('page-tree print-box preservation (pdfnative 1.7.0)', () => { + it('merge preserves /TrimBox and /BleedBox on every page (bytes + parsed)', async () => { + const a = await renderPrintPages(1, { bleed: 8.5 }); + const b = await renderPrintPages(1, { bleed: 8.5 }); + const out = tmpPath('merged-print.pdf'); + await merge(parseArgs([a, b, '--output', out])); + const bytes = await fs.readFile(out); + expect(bytes.includes(Buffer.from('/TrimBox'))).toBe(true); + const pages = inspectPagesResult(await inspectPages(out), 2); + for (const p of pages) { + expectTrimFromBleed(p, 8.5); + expect(p['bleedBox']).toEqual([0, 0, p['width'], p['height']]); + } + }); + + it('split preserves /TrimBox in each output file', async () => { + const doc = await renderPrintPages(2, { bleed: 8.5 }); + const outDir = tmpPath('splitdir-print'); + await split(parseArgs(['--input', doc, '--output-dir', outDir])); + const files = (await fs.readdir(outDir)).filter((f) => f.endsWith('.pdf')); + expect(files).toHaveLength(2); + for (const f of files) { + const pages = inspectPagesResult(await inspectPages(path.join(outDir, f)), 1); + expectTrimFromBleed(pages[0] as Record, 8.5); + } + }); + + it('extract preserves /TrimBox on the extracted page', async () => { + const doc = await renderPrintPages(3, { bleed: 8.5 }); + const out = tmpPath('extracted-print.pdf'); + await extract(parseArgs(['--input', doc, '--pages', '2', '--output', out])); + expect((await fs.readFile(out)).includes(Buffer.from('/TrimBox'))).toBe(true); + const pages = inspectPagesResult(await inspectPages(out), 1); + expectTrimFromBleed(pages[0] as Record, 8.5); + }); + + it('extract preserves /UserUnit', async () => { + const doc = await renderPrintPages(2, { userUnit: 2 }); + const out = tmpPath('extracted-uu.pdf'); + await extract(parseArgs(['--input', doc, '--pages', '1', '--output', out])); + const pages = inspectPagesResult(await inspectPages(out), 1); + expect(pages[0]?.['userUnit']).toBe(2); + }); + + it('merge preserves /UserUnit on every page', async () => { + const a = await renderPrintPages(1, { userUnit: 3 }); + const b = await renderPrintPages(1, { userUnit: 3 }); + const out = tmpPath('merged-uu.pdf'); + await merge(parseArgs([a, b, '--output', out])); + const pages = inspectPagesResult(await inspectPages(out), 2); + for (const p of pages) expect(p['userUnit']).toBe(3); + }); +}); + +/** Assert the inspect --pages payload has the expected page count, then return it. */ +function inspectPagesResult(pages: Array>, expected: number): Array> { + expect(Array.isArray(pages)).toBe(true); + expect(pages).toHaveLength(expected); + return pages; +} + describe('page-tree drop-annotations', () => { it('merge --drop-annotations still produces a valid PDF', async () => { const a = await renderPages(1); diff --git a/tests/commands/render-charts.test.ts b/tests/commands/render-charts.test.ts new file mode 100644 index 0000000..507c890 --- /dev/null +++ b/tests/commands/render-charts.test.ts @@ -0,0 +1,180 @@ +// Chart-block coverage for pdfnative 1.7.0 (charts v2): all nine chart +// types, positional (linear/time) x-axes, log scales, dual value axes, +// data labels, and label rotation — driven end-to-end through the CLI +// `render` command. + +import { describe, it, expect, afterEach } from 'vitest'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as fs from 'node:fs/promises'; +import { render } from '../../src/commands/render.js'; +import { parseArgs } from '../../src/utils/args.js'; +import { CliError, ErrorCode } from '../../src/utils/error.js'; +import { openPdf } from '../../src/core-bridge/index.js'; + +const tmp: string[] = []; + +afterEach(async () => { + for (const f of tmp.splice(0)) { + await fs.rm(f, { force: true }).catch(() => undefined); + } +}); + +function tmpPath(name: string): string { + const p = path.join(os.tmpdir(), `render-charts-${Date.now()}-${Math.random().toString(36).slice(2)}-${name}`); + tmp.push(p); + return p; +} + +async function writeDoc(blocks: readonly unknown[]): Promise { + const p = tmpPath('in.json'); + await fs.writeFile(p, JSON.stringify({ blocks }), 'utf8'); + return p; +} + +/** Render `blocks` and return the output PDF bytes after basic validation. */ +async function renderBlocks(blocks: readonly unknown[]): Promise { + const input = await writeDoc(blocks); + const out = tmpPath('out.pdf'); + await render(parseArgs(['--input', input, '--output', out])); + const bytes = await fs.readFile(out); + expect(bytes.subarray(0, 4).toString('ascii')).toBe('%PDF'); + expect(bytes.toString('latin1')).toContain('%%EOF'); + return bytes; +} + +const CATEGORIES = ['Q1', 'Q2', 'Q3', 'Q4'] as const; +const SERIES_A = { label: 'North', values: [12, 19, 7, 15] }; +const SERIES_B = { label: 'South', values: [8, 11, 14, 9] }; + +/** Minimal valid chart block per type (scatter needs a positional x-axis). */ +function chartBlockFor(chartType: string): Record { + if (chartType === 'pie' || chartType === 'donut') { + return { + type: 'chart', chartType, + categories: ['A', 'B', 'C'], + series: [{ label: 'Share', values: [50, 30, 20] }], + }; + } + if (chartType === 'scatter') { + return { + type: 'chart', chartType, + xAxis: { type: 'linear' }, + series: [{ label: 'Points', values: [3, 7, 4, 9], xValues: [1, 2, 5, 8] }], + }; + } + return { + type: 'chart', chartType, + categories: [...CATEGORIES], + series: [SERIES_A, SERIES_B], + }; +} + +describe('render chart blocks — all nine chart types (pdfnative 1.7.0)', () => { + const ALL_TYPES = [ + 'bar', 'barH', 'line', 'pie', 'donut', + 'stackedBar', 'stackedBarH', 'area', 'scatter', + ] as const; + + for (const chartType of ALL_TYPES) { + it(`renders a valid single-page PDF for chartType "${chartType}"`, async () => { + const bytes = await renderBlocks([ + { type: 'heading', text: `Chart: ${chartType}`, level: 1 }, + chartBlockFor(chartType), + ]); + const reader = openPdf(new Uint8Array(bytes)); + expect(reader.pageCount).toBe(1); + }); + } +}); + +describe('render chart blocks — charts v2 options', () => { + it('scatter with numeric xValues on a linear x-axis', async () => { + await renderBlocks([{ + type: 'chart', chartType: 'scatter', + title: 'Latency vs load', + xAxis: { type: 'linear', min: 0, max: 100, grid: true }, + series: [ + { label: 'p50', values: [12, 15, 22, 40], xValues: [10, 30, 60, 90] }, + { label: 'p99', values: [30, 44, 71, 120], xValues: [10, 30, 60, 90] }, + ], + }]); + }); + + it('line with a log value-axis scale (strictly positive values)', async () => { + await renderBlocks([{ + type: 'chart', chartType: 'line', + categories: ['a', 'b', 'c', 'd'], + axis: { scale: 'log' }, + series: [{ label: 'Growth', values: [1, 10, 100, 1000] }], + }]); + }); + + it('secondary right axis via series yAxis + axis2', async () => { + await renderBlocks([{ + type: 'chart', chartType: 'line', + categories: [...CATEGORIES], + axis: { grid: true }, + axis2: { yMin: 0, yMax: 100, ticks: 5 }, + series: [ + SERIES_A, + { label: 'Utilisation %', values: [55, 61, 48, 72], yAxis: 'right' }, + ], + }]); + }); + + it('dataLabels: bare boolean and customised prefix/suffix/decimals', async () => { + await renderBlocks([ + { + type: 'chart', chartType: 'bar', + categories: [...CATEGORIES], + series: [SERIES_A], + dataLabels: true, + }, + { + type: 'chart', chartType: 'bar', + categories: [...CATEGORIES], + series: [SERIES_B], + dataLabels: { decimals: 1, prefix: '$', suffix: 'M' }, + }, + ]); + }); + + it('time x-axis with ISO-8601 xValues', async () => { + await renderBlocks([{ + type: 'chart', chartType: 'line', + xAxis: { type: 'time', grid: true }, + series: [{ + label: 'Signups', + values: [5, 9, 14, 11], + xValues: ['2026-01-01', '2026-02-01', '2026-03-01', '2026-04-01'], + }], + }]); + }); + + it('labelRotation renders rotated category labels', async () => { + await renderBlocks([{ + type: 'chart', chartType: 'bar', + categories: ['January 2026', 'February 2026', 'March 2026', 'April 2026'], + labelRotation: 45, + series: [SERIES_A], + }]); + }); + + it('log scale with non-positive values → CliError E_INPUT (exit 1)', async () => { + // pdfnative throws `chart: series "…" has non-positive values on a log + // axis`; the CLI maps `chart:` build errors to E_INPUT / exit 1. + const input = await writeDoc([{ + type: 'chart', chartType: 'line', + categories: ['a', 'b'], + axis: { scale: 'log' }, + series: [{ label: 'Bad', values: [0, 10] }], + }]); + const err = await render(parseArgs(['--input', input, '--output', tmpPath('x.pdf')])) + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).exitCode).toBe(1); + expect((err as CliError).code).toBe(ErrorCode.INPUT); + expect((err as CliError).message).toContain('log'); + }); +}); diff --git a/tests/commands/render-enhancements.test.ts b/tests/commands/render-enhancements.test.ts index f41b922..a19b52b 100644 --- a/tests/commands/render-enhancements.test.ts +++ b/tests/commands/render-enhancements.test.ts @@ -2,9 +2,10 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; import * as os from 'node:os'; import * as path from 'node:path'; import * as fs from 'node:fs/promises'; +import * as zlib from 'node:zlib'; import { render } from '../../src/commands/render.js'; import { parseArgs } from '../../src/utils/args.js'; -import { CliError } from '../../src/utils/error.js'; +import { CliError, ErrorCode } from '../../src/utils/error.js'; const tmp: string[] = []; @@ -127,3 +128,383 @@ describe('render --font math', () => { expect((await fs.readFile(out)).subarray(0, 4).toString('ascii')).toBe('%PDF'); }); }); + +// ───────────────────────────────────────────────────────────────────────── +// pdfnative 1.7.0 — print production, viewer prefs, metadata, outputIntent, +// --strict + diagnostics, image-block resolution, --chunk-size. +// ───────────────────────────────────────────────────────────────────────── + +async function writeLayout(layout: unknown): Promise { + const p = tmpPath('layout.json'); + await fs.writeFile(p, JSON.stringify(layout), 'utf8'); + return p; +} + +function captureStderr(): { lines: string[]; restore: () => void } { + const lines: string[] = []; + const spy = vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => { + lines.push(String(chunk)); + return true; + }) as never); + return { lines, restore: () => spy.mockRestore() }; +} + +// ── Minimal deterministic 1×1 PNG built in-test (no fixture files) ─────── + +function crc32(buf: Uint8Array): number { + let crc = 0xFFFFFFFF; + for (const byte of buf) { + crc ^= byte; + for (let k = 0; k < 8; k++) { + crc = (crc & 1) !== 0 ? 0xEDB88320 ^ (crc >>> 1) : crc >>> 1; + } + } + return (crc ^ 0xFFFFFFFF) >>> 0; +} + +function pngChunk(type: string, data: Uint8Array): Buffer { + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length); + const body = Buffer.concat([Buffer.from(type, 'ascii'), Buffer.from(data)]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(body)); + return Buffer.concat([len, body, crc]); +} + +/** 1×1 red pixel, 8-bit RGB, single IDAT — a fully valid minimal PNG. */ +function makePng1x1(): Buffer { + const sig = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(1, 0); // width + ihdr.writeUInt32BE(1, 4); // height + ihdr[8] = 8; // bit depth + ihdr[9] = 2; // colour type: truecolour RGB + const idat = zlib.deflateSync(Buffer.from([0, 255, 0, 0])); // filter 0 + red px + return Buffer.concat([ + sig, + pngChunk('IHDR', ihdr), + pngChunk('IDAT', idat), + pngChunk('IEND', new Uint8Array(0)), + ]); +} + +describe('render print production (layout.print, pdfnative 1.7.0)', () => { + it('print.bleed emits /TrimBox and /BleedBox page boxes', async () => { + const input = await writeDoc([{ type: 'paragraph', text: 'bleed test' }]); + const layout = await writeLayout({ print: { bleed: 9 } }); + const out = tmpPath('bleed.pdf'); + await render(parseArgs(['--input', input, '--output', out, '--layout', layout])); + const raw = (await fs.readFile(out)).toString('latin1'); + expect(raw).toContain('/TrimBox'); + expect(raw).toContain('/BleedBox'); + }); + + it('print.marks with an explicit trimBox renders crop marks', async () => { + const input = await writeDoc([{ type: 'paragraph', text: 'marks test' }]); + const layout = await writeLayout({ + print: { trimBox: [30, 30, 565.28, 811.89], marks: true }, + }); + const out = tmpPath('marks.pdf'); + await render(parseArgs(['--input', input, '--output', out, '--layout', layout])); + const raw = (await fs.readFile(out)).toString('latin1'); + expect(raw).toContain('/TrimBox'); + expect(raw.startsWith('%PDF')).toBe(true); + }); + + it('print.userUnit emits /UserUnit', async () => { + const input = await writeDoc([{ type: 'paragraph', text: 'banner' }]); + const layout = await writeLayout({ print: { userUnit: 2 } }); + const out = tmpPath('userunit.pdf'); + await render(parseArgs(['--input', input, '--output', out, '--layout', layout])); + expect((await fs.readFile(out)).toString('latin1')).toContain('/UserUnit'); + }); + + it('print.userUnit under pdfa1b → CliError E_INPUT (exit 1)', async () => { + const input = await writeDoc([{ type: 'paragraph', text: 'x' }]); + const layout = await writeLayout({ print: { userUnit: 2 } }); + const stderr = captureStderr(); // swallow the PDF/A no-fontEntries warning + try { + const err = await render(parseArgs([ + '--input', input, '--output', tmpPath('x.pdf'), + '--layout', layout, '--tagged', 'pdfa1b', + ])).catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).exitCode).toBe(1); + expect((err as CliError).code).toBe(ErrorCode.INPUT); + expect((err as CliError).message).toContain('userUnit'); + } finally { + stderr.restore(); + } + }); +}); + +describe('render viewer preferences (1.7.0 print-dialog keys)', () => { + it('duplex / numCopies / printPageRange / pickTrayByPDFSize reach the catalog', async () => { + const input = await writeDoc([ + { type: 'paragraph', text: 'page 1' }, + { type: 'pageBreak' }, + { type: 'paragraph', text: 'page 2' }, + ]); + const layout = await writeLayout({ + viewerPreferences: { + duplex: 'duplexFlipLongEdge', + numCopies: 3, + printPageRange: [[1, 2]], + pickTrayByPDFSize: true, + }, + }); + const out = tmpPath('viewerprefs.pdf'); + await render(parseArgs(['--input', input, '--output', out, '--layout', layout])); + const raw = (await fs.readFile(out)).toString('latin1'); + expect(raw).toContain('/Duplex /DuplexFlipLongEdge'); + expect(raw).toContain('/NumCopies 3'); + expect(raw).toContain('/PrintPageRange'); + expect(raw).toContain('/PickTrayByPDFSize true'); + }); +}); + +describe('render metadata (DocumentParams.metadata, 1.7.0 trapped)', () => { + it('author / subject / keywords / trapped reach /Info', async () => { + const input = tmpPath('meta.json'); + await fs.writeFile(input, JSON.stringify({ + blocks: [{ type: 'paragraph', text: 'meta test' }], + metadata: { + author: 'Meta Author', + subject: 'Meta Subject', + keywords: 'alpha, beta', + trapped: 'True', + }, + }), 'utf8'); + const out = tmpPath('meta.pdf'); + await render(parseArgs(['--input', input, '--output', out])); + const raw = (await fs.readFile(out)).toString('latin1'); + expect(raw).toContain('Meta Author'); + expect(raw).toContain('Meta Subject'); + expect(raw).toContain('alpha, beta'); + expect(raw).toContain('/Trapped /True'); + }); +}); + +describe('render outputIntent (custom ICC profile via --layout)', () => { + it('embeds a caller-supplied RGB profile under a tagged mode', async () => { + // Minimal fake ICC profile: pdfnative validates a 128-byte header and + // an "RGB " data colour space at bytes 16–19, then embeds the bytes. + const icc = new Uint8Array(128); + icc[16] = 0x52; icc[17] = 0x47; icc[18] = 0x42; icc[19] = 0x20; // "RGB " + const input = await writeDoc([{ type: 'paragraph', text: 'intent' }]); + const layout = await writeLayout({ + outputIntent: { + iccProfile: Array.from(icc), + outputConditionIdentifier: 'Fake RGB Test Profile', + }, + }); + const out = tmpPath('intent.pdf'); + const stderr = captureStderr(); // swallow the PDF/A no-fontEntries warning + try { + await render(parseArgs([ + '--input', input, '--output', out, + '--layout', layout, '--tagged', 'pdfa2b', + ])); + } finally { + stderr.restore(); + } + const raw = (await fs.readFile(out)).toString('latin1'); + expect(raw).toContain('Fake RGB Test Profile'); + }); +}); + +describe('render --strict + PDF/A diagnostics (pdfnative 1.7.0)', () => { + const origJson = process.env['PDFNATIVE_JSON']; + + afterEach(() => { + if (origJson === undefined) delete process.env['PDFNATIVE_JSON']; + else process.env['PDFNATIVE_JSON'] = origJson; + }); + + it('--strict succeeds on a document with no conformance findings', async () => { + const input = await writeDoc([{ type: 'paragraph', text: 'clean' }]); + const out = tmpPath('strict-ok.pdf'); + await render(parseArgs(['--input', input, '--output', out, '--strict'])); + expect((await fs.readFile(out)).subarray(0, 4).toString('ascii')).toBe('%PDF'); + }); + + it('--strict escalates a PDF/A violation to E_CHECK_FAILED before any output', async () => { + const input = await writeDoc([{ type: 'paragraph', text: 'x' }]); + const out = tmpPath('strict-fail.pdf'); + // pdfa2b with no fontEntries → PDFA_NO_FONT_ENTRIES, thrown under strict. + const err = await render(parseArgs([ + '--input', input, '--output', out, '--tagged', 'pdfa2b', '--strict', + ])).catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).exitCode).toBe(1); + expect((err as CliError).code).toBe(ErrorCode.CHECK_FAILED); + expect((err as CliError).message).toContain('PDF/A'); + await expect(fs.stat(out)).rejects.toThrow(); // no partial output + }); + + it('without --strict the violation is a stderr warning and the PDF renders', async () => { + const input = await writeDoc([{ type: 'paragraph', text: 'x' }]); + const out = tmpPath('warn.pdf'); + const stderr = captureStderr(); + try { + await render(parseArgs(['--input', input, '--output', out, '--tagged', 'pdfa2b'])); + } finally { + stderr.restore(); + } + const warnings = stderr.lines.filter((l) => l.startsWith('warning: [')); + expect(warnings.some((l) => l.includes('[PDFA_NO_FONT_ENTRIES]'))).toBe(true); + expect((await fs.readFile(out)).subarray(0, 4).toString('ascii')).toBe('%PDF'); + }); + + it('--json status envelope carries an additive diagnostics array', async () => { + process.env['PDFNATIVE_JSON'] = '1'; + const input = await writeDoc([{ type: 'paragraph', text: 'x' }]); + const out = tmpPath('diag.pdf'); + const stderr = captureStderr(); + try { + await render(parseArgs(['--input', input, '--output', out, '--tagged', 'pdfa2b'])); + } finally { + stderr.restore(); + } + const envelopes = stderr.lines + .map((l) => l.trim()) + .filter((l) => l.startsWith('{')) + .map((l) => JSON.parse(l) as Record); + const status = envelopes.at(-1); + expect(status).toMatchObject({ ok: true, command: 'render' }); + const diags = status?.diagnostics as readonly Record[]; + expect(Array.isArray(diags)).toBe(true); + expect(diags[0]).toMatchObject({ + code: 'PDFA_NO_FONT_ENTRIES', + severity: 'warning', + }); + expect(typeof diags[0]?.message).toBe('string'); + }); + + it('omits the diagnostics field when there are no findings', async () => { + process.env['PDFNATIVE_JSON'] = '1'; + const input = await writeDoc([{ type: 'paragraph', text: 'clean' }]); + const out = tmpPath('nodiag.pdf'); + const stderr = captureStderr(); + try { + await render(parseArgs(['--input', input, '--output', out])); + } finally { + stderr.restore(); + } + const status = stderr.lines + .map((l) => l.trim()) + .filter((l) => l.startsWith('{')) + .map((l) => JSON.parse(l) as Record) + .at(-1); + expect(status).toMatchObject({ ok: true, command: 'render' }); + expect(status).not.toHaveProperty('diagnostics'); + }); +}); + +describe('render image blocks — src / dataBase64 resolution', () => { + it('renders a dataBase64 PNG payload as an image XObject', async () => { + const b64 = makePng1x1().toString('base64'); + const input = await writeDoc([ + { type: 'paragraph', text: 'inline image' }, + { type: 'image', dataBase64: b64, width: 24, height: 24, alt: '1x1 red' }, + ]); + const out = tmpPath('img-b64.pdf'); + await render(parseArgs(['--input', input, '--output', out])); + const raw = (await fs.readFile(out)).toString('latin1'); + expect(raw.startsWith('%PDF')).toBe(true); + expect(raw).toContain('/XObject'); + }); + + it('resolves a relative src against the --input file directory', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'render-img-src-')); + tmp.push(dir); + await fs.writeFile(path.join(dir, 'pixel.png'), makePng1x1()); + const input = path.join(dir, 'doc.json'); + await fs.writeFile(input, JSON.stringify({ + blocks: [{ type: 'image', src: 'pixel.png', width: 24, height: 24 }], + }), 'utf8'); + const out = tmpPath('img-src.pdf'); + await render(parseArgs(['--input', input, '--output', out])); + const raw = (await fs.readFile(out)).toString('latin1'); + expect(raw).toContain('/XObject'); + await fs.rm(dir, { recursive: true, force: true }); + }); + + it('revives a JSON number-array data payload', async () => { + const input = await writeDoc([ + { type: 'image', data: Array.from(makePng1x1()), width: 24, height: 24 }, + ]); + const out = tmpPath('img-arr.pdf'); + await render(parseArgs(['--input', input, '--output', out])); + expect((await fs.readFile(out)).toString('latin1')).toContain('/XObject'); + }); + + it('unreadable src → CliError E_IO (exit 1)', async () => { + const input = await writeDoc([ + { type: 'image', src: 'definitely-not-here.png' }, + ]); + const err = await render(parseArgs(['--input', input, '--output', tmpPath('x.pdf')])) + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).exitCode).toBe(1); + expect((err as CliError).code).toBe(ErrorCode.IO); + }); + + it('invalid base64 → CliError E_INPUT (exit 1)', async () => { + const input = await writeDoc([ + { type: 'image', dataBase64: '@@not/base64@@' }, + ]); + const err = await render(parseArgs(['--input', input, '--output', tmpPath('x.pdf')])) + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).exitCode).toBe(1); + expect((err as CliError).code).toBe(ErrorCode.INPUT); + }); + + it('src with path traversal is rejected', async () => { + const input = await writeDoc([ + { type: 'image', src: '../../etc/secret.png' }, + ]); + await expect( + render(parseArgs(['--input', input, '--output', tmpPath('x.pdf')])), + ).rejects.toThrowError(/traversal/i); + }); +}); + +describe('render --chunk-size', () => { + it('--stream --chunk-size output is byte-identical to buffered output', async () => { + const input = await writeDoc([ + { type: 'heading', text: 'Chunked', level: 1 }, + { type: 'paragraph', text: 'chunk-size parity test '.repeat(50) }, + ]); + const buffered = tmpPath('buf.pdf'); + const chunked = tmpPath('chunked.pdf'); + await render(parseArgs(['--input', input, '--output', buffered])); + await render(parseArgs([ + '--input', input, '--output', chunked, '--stream', '--chunk-size', '512', + ])); + const a = await fs.readFile(buffered); + const b = await fs.readFile(chunked); + expect(b.equals(a)).toBe(true); + }); + + it('rejects non-positive / non-integer values (exit 2)', async () => { + const input = await writeDoc([{ type: 'paragraph', text: 'x' }]); + for (const bad of ['0', '-1', 'abc', '2.5']) { + const err = await render(parseArgs([ + '--input', input, '--output', '-', '--stream', '--chunk-size', bad, + ])).catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).exitCode).toBe(2); + } + }); + + it('rejects --chunk-size with --stream-page-by-page (exit 2)', async () => { + const input = await writeDoc([{ type: 'paragraph', text: 'x' }]); + const err = await render(parseArgs([ + '--input', input, '--output', '-', '--stream-page-by-page', '--chunk-size', '1024', + ])).catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).exitCode).toBe(2); + }); +}); diff --git a/tests/commands/schema.test.ts b/tests/commands/schema.test.ts index 10fb38a..70339b1 100644 --- a/tests/commands/schema.test.ts +++ b/tests/commands/schema.test.ts @@ -28,7 +28,7 @@ describe('schema', () => { it.each([ 'render', 'inspect', 'verify', 'batch', 'annotate', 'extract-text', 'fill', 'form-export', 'inspect-summary', 'verify-summary', 'batch-summary', - 'govern-verify', 'status', 'doctor', + 'govern-verify', 'metadata', 'ltv-data', 'compare', 'batch-manifest', 'status', 'doctor', ])( 'prints a valid Draft 2020-12 schema for "%s"', async (subject) => { @@ -85,6 +85,10 @@ describe('schema', () => { 'verify-summary', 'batch-summary', 'govern-verify', + 'metadata', + 'ltv-data', + 'compare', + 'batch-manifest', 'status', 'manifest', 'doctor', @@ -109,4 +113,37 @@ describe('schema', () => { expect(err.code).toBe(ErrorCode.USAGE); }); }); + + it('should cover every key the batch manifest-mode envelope emits (full and --summary)', async () => { + // Guard against the pinned schema rejecting the real output: every key + // emitted by `batch --manifest` (see emitManifestSummary in batch.ts) + // must be declared, because both schemas set additionalProperties:false. + const manifestModeKeys = ['ok', 'command', 'mode', 'dryRun', 'total', 'succeeded', 'failed', 'skipped']; + + const out = captureStdout(); + await schema(parseArgs(['batch'])); + out.restore(); + const full = JSON.parse(out.calls.join('')); + for (const key of [...manifestModeKeys, 'tasks', 'results']) { + expect(Object.keys(full.properties), `batch schema missing "${key}"`).toContain(key); + } + + const out2 = captureStdout(); + await schema(parseArgs(['batch-summary'])); + out2.restore(); + const summary = JSON.parse(out2.calls.join('')); + for (const key of manifestModeKeys) { + expect(Object.keys(summary.properties), `batch-summary schema missing "${key}"`).toContain(key); + } + }); + + it('should print the metadata --from-json input schema', async () => { + const out = captureStdout(); + await schema(parseArgs(['metadata'])); + out.restore(); + const doc = JSON.parse(out.calls.join('')); + expect(doc.title).toBe('pdfnative-cli metadata input'); + expect(Object.keys(doc.properties)).toEqual(['title', 'author', 'subject', 'keywords', 'modDate']); + expect(doc.additionalProperties).toBe(false); + }); }); diff --git a/tests/commands/sign.test.ts b/tests/commands/sign.test.ts index 2ef97fd..7c8789d 100644 --- a/tests/commands/sign.test.ts +++ b/tests/commands/sign.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, afterEach, vi } from 'vitest'; +import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from 'vitest'; import * as os from 'node:os'; import * as path from 'node:path'; import * as fs from 'node:fs/promises'; @@ -6,7 +6,9 @@ import { fileURLToPath } from 'node:url'; import { sign } from '../../src/commands/sign.js'; import { render } from '../../src/commands/render.js'; import { parseArgs } from '../../src/utils/args.js'; -import { CliError } from '../../src/utils/error.js'; +import { CliError, ErrorCode } from '../../src/utils/error.js'; +import { setTimestampProvider, listSignatures, ensureCryptoReady } from '../../src/core-bridge/index.js'; +import { createMockPki, createMockTimestampProvider } from '../helpers/mock-pki.js'; const FIXTURES = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'fixtures'); const RSA_KEY = path.join(FIXTURES, 'rsa-key.pem'); @@ -64,6 +66,21 @@ const minimalParams = JSON.stringify({ blocks: [{ type: 'paragraph', text: 'Hello world' }], }); +/** DER bytes of id-aa-signatureTimeStampToken (1.2.840.113549.1.9.16.2.14). */ +const OID_SIGNATURE_TIMESTAMP: readonly number[] = [ + 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x09, 0x10, 0x02, 0x0e, +]; + +function containsBytes(haystack: Uint8Array, needle: readonly number[]): boolean { + outer: for (let i = 0; i + needle.length <= haystack.length; i++) { + for (let j = 0; j < needle.length; j++) { + if (haystack[i + j] !== needle[j]) continue outer; + } + return true; + } + return false; +} + describe('sign', () => { const tmpFiles: string[] = []; @@ -85,6 +102,25 @@ describe('sign', () => { return pdfPath; } + /** Sign `pdfPath` with the RSA PEM fixtures plus `extra` flags; returns the output path. */ + async function signFixture(pdfPath: string, extra: readonly string[] = []): Promise { + const outPath = path.join(os.tmpdir(), `sign-out-${Date.now()}-${Math.random()}.pdf`); + tmpFiles.push(outPath); + await sign(parseArgs([ + '--input', pdfPath, + '--output', outPath, + '--key', RSA_KEY, + '--cert', RSA_CERT, + ...extra, + ])); + return outPath; + } + + async function signaturesOf(pdfPath: string): Promise> { + const bytes = await fs.readFile(pdfPath); + return listSignatures(new Uint8Array(bytes)); + } + it('throws CliError(2) when no key is provided (no env, no flag)', async () => { const pdfPath = await makeTestPdf(); const outPath = path.join(os.tmpdir(), `sign-out-${Date.now()}.pdf`); @@ -274,4 +310,283 @@ describe('sign', () => { }); }); }); + + // ────────────────────────────────────────────────────────────────── + // v1.4.0 — sign-side RFC 3161 timestamping (PAdES B-T, offline mock TSA) + // ────────────────────────────────────────────────────────────────── + + describe('--timestamp (PAdES B-T)', () => { + const TSA_URL = 'http://tsa.mock.invalid/tsr'; + + beforeAll(async () => { + await ensureCryptoReady(); + }); + + beforeEach(() => { + // The global provider injected here beats the CLI's HTTP transport + // — this is the sanctioned offline test seam. Zero network. + setTimestampProvider(createMockTimestampProvider(createMockPki())); + }); + + afterEach(() => { + setTimestampProvider(null); + }); + + it('signs and embeds an RFC 3161 timestamp token', async () => { + const out = await signFixture(await makeTestPdf(), ['--timestamp', TSA_URL]); + const bytes = await fs.readFile(out); + expect(bytes.slice(0, 4).toString('ascii')).toBe('%PDF'); + + const sigs = listSignatures(new Uint8Array(bytes)); + expect(sigs).toHaveLength(1); + expect(sigs[0]!.isPlaceholder).toBe(false); + expect(sigs[0]!.byteRange).toHaveLength(4); + // The id-aa-signatureTimeStampToken unsigned attribute is inside /Contents. + expect(containsBytes(sigs[0]!.contents, OID_SIGNATURE_TIMESTAMP)).toBe(true); + }); + + it('--json success envelope carries the additive timestamp field', async () => { + const pdfPath = await makeTestPdf(); + process.env['PDFNATIVE_JSON'] = '1'; + const lines: string[] = []; + const spy = vi.spyOn(process.stderr, 'write').mockImplementation((c: unknown) => { + lines.push(String(c)); + return true; + }); + try { + await signFixture(pdfPath, ['--timestamp', TSA_URL]); + } finally { + spy.mockRestore(); + delete process.env['PDFNATIVE_JSON']; + } + const envelope = lines.map((l) => l.trim()).filter(Boolean).map((l) => JSON.parse(l)).at(-1); + expect(envelope).toMatchObject({ + ok: true, + command: 'sign', + dryRun: false, + timestamp: { url: TSA_URL, digest: 'sha256' }, + }); + }); + + it('fails with E_PARSE (exit 1) and writes nothing when the TSA rejects (PKIStatus 2)', async () => { + setTimestampProvider(createMockTimestampProvider(createMockPki(), { status: 2 })); + const pdfPath = await makeTestPdf(); + const outPath = path.join(os.tmpdir(), `sign-tsa-rej-${Date.now()}-${Math.random()}.pdf`); + tmpFiles.push(outPath); + const err = await sign(parseArgs([ + '--input', pdfPath, + '--output', outPath, + '--key', RSA_KEY, + '--cert', RSA_CERT, + '--timestamp', TSA_URL, + ])).catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).exitCode).toBe(1); + expect((err as CliError).code).toBe(ErrorCode.PARSE); + // No silent fallback to an untimestamped signature: no output written. + await expect(fs.stat(outPath)).rejects.toThrow(); + }); + + it('rejects a non-http(s) --timestamp URL with a usage error', async () => { + const err = await sign(parseArgs([ + '--input', await makeTestPdf(), + '--key', RSA_KEY, + '--cert', RSA_CERT, + '--timestamp', 'ftp://tsa.example.com/tsr', + ])).catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).exitCode).toBe(2); + }); + + it('rejects an invalid --timestamp-digest', async () => { + const err = await sign(parseArgs([ + '--input', await makeTestPdf(), + '--key', RSA_KEY, + '--cert', RSA_CERT, + '--timestamp', TSA_URL, + '--timestamp-digest', 'md5', + ])).catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).exitCode).toBe(2); + }); + + it('rejects an invalid --timestamp-nonce (non-hex)', async () => { + const err = await sign(parseArgs([ + '--input', await makeTestPdf(), + '--key', RSA_KEY, + '--cert', RSA_CERT, + '--timestamp', TSA_URL, + '--timestamp-nonce', 'not-hex!', + ])).catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).exitCode).toBe(2); + }); + + it('honours an explicit --timestamp-nonce and --timestamp-digest sha384', async () => { + const out = await signFixture(await makeTestPdf(), [ + '--timestamp', TSA_URL, + '--timestamp-nonce', 'deadbeef', + '--timestamp-digest', 'sha384', + ]); + const sigs = await signaturesOf(out); + expect(sigs).toHaveLength(1); + expect(sigs[0]!.isPlaceholder).toBe(false); + expect(containsBytes(sigs[0]!.contents, OID_SIGNATURE_TIMESTAMP)).toBe(true); + }); + + it('--dry-run with --timestamp never calls the provider and exits cleanly', async () => { + let calls = 0; + setTimestampProvider({ + getTimestamp: () => { + calls++; + return Promise.reject(new Error('network attempted during dry-run')); + }, + }); + const pdfPath = await makeTestPdf(); + const outPath = path.join(os.tmpdir(), `sign-ts-dry-${Date.now()}-${Math.random()}.pdf`); + tmpFiles.push(outPath); + await sign(parseArgs([ + '--input', pdfPath, + '--output', outPath, + '--key', RSA_KEY, + '--cert', RSA_CERT, + '--timestamp', TSA_URL, + '--dry-run', + ])); + expect(calls).toBe(0); + await expect(fs.stat(outPath)).rejects.toThrow(); + }); + }); + + // ────────────────────────────────────────────────────────────────── + // v1.4.0 — multi-signature + 1.7.0 signing options + // ────────────────────────────────────────────────────────────────── + + describe('multi-signature & 1.7.0 options', () => { + beforeAll(async () => { + await ensureCryptoReady(); + }); + + it('re-signing without --allow-multiple fails (1.x idempotent short-circuit)', async () => { + // addSignaturePlaceholder returns the already-signed PDF unchanged + // (1.x idempotence) and signPdfBytes then finds no unsigned + // placeholder → CliError E_SIGN, exit 1. The first signature is + // never modified. + const first = await signFixture(await makeTestPdf()); + const err = await sign(parseArgs([ + '--input', first, + '--key', RSA_KEY, + '--cert', RSA_CERT, + ])).catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).exitCode).toBe(1); + expect((err as CliError).code).toBe(ErrorCode.SIGN); + const sigs = await signaturesOf(first); + expect(sigs).toHaveLength(1); + expect(sigs[0]!.isPlaceholder).toBe(false); + }); + + it('--allow-multiple + --field-name adds a second signature', async () => { + const first = await signFixture(await makeTestPdf()); + const second = await signFixture(first, ['--allow-multiple', '--field-name', 'Signature2']); + const sigs = await signaturesOf(second); + expect(sigs).toHaveLength(2); + expect(sigs.every((s) => !s.isPlaceholder)).toBe(true); + const names = sigs.map((s) => s.fieldName); + expect(names).toContain('Signature1'); + expect(names).toContain('Signature2'); + }); + + it('--field-name is reflected in listSignatures', async () => { + const out = await signFixture(await makeTestPdf(), ['--field-name', 'ApprovalSig']); + const sigs = await signaturesOf(out); + expect(sigs).toHaveLength(1); + expect(sigs[0]!.fieldName).toBe('ApprovalSig'); + expect(sigs[0]!.isPlaceholder).toBe(false); + }); + + it('--digest sha384 produces a signed PDF (native crypto path)', async () => { + const out = await signFixture(await makeTestPdf(), ['--digest', 'sha384']); + const bytes = await fs.readFile(out); + expect(bytes.slice(0, 4).toString('ascii')).toBe('%PDF'); + const sigs = listSignatures(new Uint8Array(bytes)); + expect(sigs).toHaveLength(1); + expect(sigs[0]!.isPlaceholder).toBe(false); + }); + + it('rejects an invalid --digest', async () => { + const err = await sign(parseArgs([ + '--input', await makeTestPdf(), + '--key', RSA_KEY, + '--cert', RSA_CERT, + '--digest', 'sha1', + ])).catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).exitCode).toBe(2); + }); + + it('rejects --digest sha384 with ecdsa-sha256 (P-256 is SHA-256 only)', async () => { + const err = await sign(parseArgs([ + '--input', await makeTestPdf(), + '--key', RSA_KEY, + '--cert', RSA_CERT, + '--algorithm', 'ecdsa-sha256', + '--digest', 'sha384', + ])).catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).exitCode).toBe(2); + }); + + it('--profile pades yields an ETSI.CAdES.detached signature', async () => { + const out = await signFixture(await makeTestPdf(), ['--profile', 'pades']); + const sigs = await signaturesOf(out); + expect(sigs).toHaveLength(1); + expect(sigs[0]!.isPlaceholder).toBe(false); + expect(sigs[0]!.subFilter).toBe('ETSI.CAdES.detached'); + }); + + it('rejects an invalid --profile', async () => { + const err = await sign(parseArgs([ + '--input', await makeTestPdf(), + '--key', RSA_KEY, + '--cert', RSA_CERT, + '--profile', 'cades-lt', + ])).catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).exitCode).toBe(2); + }); + + it('accepts --signature-rect, --signature-page and --placeholder-bytes', async () => { + const out = await signFixture(await makeTestPdf(), [ + '--signature-rect', '10,10,200,80', + '--signature-page', '1', + '--placeholder-bytes', '20000', + ]); + const sigs = await signaturesOf(out); + expect(sigs).toHaveLength(1); + expect(sigs[0]!.isPlaceholder).toBe(false); + }); + + it('rejects a malformed --signature-rect', async () => { + const err = await sign(parseArgs([ + '--input', await makeTestPdf(), + '--key', RSA_KEY, + '--cert', RSA_CERT, + '--signature-rect', '10,20,30', + ])).catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).exitCode).toBe(2); + }); + + it('rejects --signature-page 0 (pages are 1-based)', async () => { + const err = await sign(parseArgs([ + '--input', await makeTestPdf(), + '--key', RSA_KEY, + '--cert', RSA_CERT, + '--signature-page', '0', + ])).catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).exitCode).toBe(2); + }); + }); }); diff --git a/tests/commands/verify.test.ts b/tests/commands/verify.test.ts index f93f312..9b678d6 100644 --- a/tests/commands/verify.test.ts +++ b/tests/commands/verify.test.ts @@ -1,11 +1,20 @@ -import { describe, it, expect, afterEach } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; import * as os from 'node:os'; import * as path from 'node:path'; import * as fs from 'node:fs/promises'; import { verify } from '../../src/commands/verify.js'; import { render } from '../../src/commands/render.js'; +import { sign } from '../../src/commands/sign.js'; +import { docTimestamp } from '../../src/commands/docTimestamp.js'; import { parseArgs } from '../../src/utils/args.js'; import { CliError, ErrorCode } from '../../src/utils/error.js'; +import { + setTimestampProvider, + derSequence, + derInteger, +} from '../../src/core-bridge/index.js'; +import type { RsaPrivateKey } from '../../src/core-bridge/index.js'; +import { createMockPki, createMockTimestampProvider, toPem } from '../helpers/mock-pki.js'; const minimalParams = JSON.stringify({ title: 'Verify Test', @@ -150,3 +159,215 @@ describe('verify', () => { }); }); }); + +// ── v1.4.0 — enriched verify: digests, PAdES, fieldName, /DocTimeStamp ── +// +// Fixtures are generated in-process with the mock PKI (real DER, real RSA +// signatures, ZERO network): the mock TSA is injected via +// setTimestampProvider so the .invalid URL is never contacted. + +interface EnrichedSignature { + readonly fieldName: string | null; + readonly subFilter: string | null; + readonly isDocTimestamp: boolean; + readonly integrity: boolean; + readonly chainValid: boolean; + readonly trustedRoot: boolean; + readonly signatureValid: boolean; + readonly signatureAlgorithm: string | null; + readonly timestampPresent: boolean; + readonly timestampValid: boolean; + readonly timestampTime: string | null; + readonly notes: readonly string[]; +} + +interface EnrichedVerifyOutput { + readonly signatures: readonly EnrichedSignature[]; + readonly allValid: boolean; +} + +/** PKCS#1 RSAPrivateKey DER (RFC 8017 A.1.2) for the mock signer key. */ +function rsaPrivateKeyPkcs1Der(key: RsaPrivateKey): Uint8Array { + return derSequence( + derInteger(0n), + derInteger(key.n), + derInteger(65537n), + derInteger(key.d), + derInteger(key.p), + derInteger(key.q), + derInteger(key.dp), + derInteger(key.dq), + derInteger(key.qi), + ); +} + +describe('verify — enriched (v1.4.0)', () => { + let dir: string; + let basePdf: string; + let keyPath: string; + let certPath: string; + let rootPath: string; + + beforeAll(async () => { + const pki = createMockPki(); + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'verify-enriched-')); + keyPath = path.join(dir, 'signer-key.pem'); + certPath = path.join(dir, 'signer-cert.pem'); + rootPath = path.join(dir, 'root-cert.pem'); + await fs.writeFile(keyPath, toPem('RSA PRIVATE KEY', rsaPrivateKeyPkcs1Der(pki.signerKey)), 'utf8'); + await fs.writeFile(certPath, toPem('CERTIFICATE', pki.signerCert.raw), 'utf8'); + await fs.writeFile(rootPath, toPem('CERTIFICATE', pki.rootCert.raw), 'utf8'); + + const paramsPath = path.join(dir, 'doc.json'); + basePdf = path.join(dir, 'doc.pdf'); + await fs.writeFile(paramsPath, minimalParams, 'utf8'); + await render(parseArgs(['--input', paramsPath, '--output', basePdf])); + }, 60000); + + afterAll(async () => { + await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined); + }); + + beforeEach(() => { + setTimestampProvider(createMockTimestampProvider(createMockPki())); + }); + + afterEach(() => { + setTimestampProvider(null); + }); + + function out(name: string): string { + return path.join(dir, `${Date.now()}-${Math.random().toString(36).slice(2)}-${name}`); + } + + async function signBase(extra: readonly string[] = []): Promise { + const signed = out('signed.pdf'); + await sign(parseArgs([ + '--input', basePdf, '--output', signed, + '--key', keyPath, '--cert', certPath, '--cert-chain', rootPath, + ...extra, + ])); + return signed; + } + + async function verifyJson(pdf: string): Promise { + const stdout = await captureStdout(() => + verify(parseArgs(['--input', pdf, '--format', 'json'])), + ); + return JSON.parse(stdout) as EnrichedVerifyOutput; + } + + async function addDocTimestamp(signed: string, extra: readonly string[] = []): Promise { + const stamped = out('lta.pdf'); + await docTimestamp(parseArgs([ + '--input', signed, '--output', stamped, '--url', 'http://tsa.mock.invalid/tsr', + ...extra, + ])); + return stamped; + } + + it('sign --digest sha384 → signatureValid with algorithm rsa-sha384', async () => { + const result = await verifyJson(await signBase(['--digest', 'sha384'])); + expect(result.signatures).toHaveLength(1); + const sig = result.signatures[0]!; + expect(sig.integrity).toBe(true); + expect(sig.signatureValid).toBe(true); + expect(sig.signatureAlgorithm).toBe('rsa-sha384'); + expect(result.allValid).toBe(true); + }); + + it('sign --digest sha512 → signatureValid with algorithm rsa-sha512', async () => { + const result = await verifyJson(await signBase(['--digest', 'sha512'])); + expect(result.signatures).toHaveLength(1); + const sig = result.signatures[0]!; + expect(sig.integrity).toBe(true); + expect(sig.signatureValid).toBe(true); + expect(sig.signatureAlgorithm).toBe('rsa-sha512'); + expect(result.allValid).toBe(true); + }); + + it('sign --profile pades (ETSI.CAdES.detached) verifies as fully valid', async () => { + const result = await verifyJson(await signBase(['--profile', 'pades'])); + expect(result.signatures).toHaveLength(1); + const sig = result.signatures[0]!; + expect(sig.subFilter).toBe('ETSI.CAdES.detached'); + expect(sig.integrity).toBe(true); + expect(sig.signatureValid).toBe(true); + expect(sig.isDocTimestamp).toBe(false); + expect(result.allValid).toBe(true); + }); + + it('reports the signature fieldName (sign --field-name MonChamp)', async () => { + const result = await verifyJson(await signBase(['--field-name', 'MonChamp'])); + expect(result.signatures).toHaveLength(1); + expect(result.signatures[0]!.fieldName).toBe('MonChamp'); + expect(result.signatures[0]!.isDocTimestamp).toBe(false); + expect(result.allValid).toBe(true); + }); + + it('sign --timestamp (mock TSA) → timestampPresent + timestampValid', async () => { + const result = await verifyJson(await signBase(['--timestamp', 'http://tsa.mock.invalid/tsr'])); + expect(result.signatures).toHaveLength(1); + const sig = result.signatures[0]!; + expect(sig.signatureValid).toBe(true); + expect(sig.timestampPresent).toBe(true); + expect(sig.timestampValid).toBe(true); + expect(sig.timestampTime).toBe('2026-02-01T12:00:00.000Z'); + expect(result.allValid).toBe(true); + }); + + it('doc-timestamp on a signed PDF → two entries, valid /DocTimeStamp, --strict exit 0', async () => { + const stamped = await addDocTimestamp(await signBase()); + const result = await verifyJson(stamped); + expect(result.signatures).toHaveLength(2); + + const [docSig, dts] = [result.signatures[0]!, result.signatures[1]!]; + expect(docSig.isDocTimestamp).toBe(false); + expect(docSig.signatureValid).toBe(true); + expect(docSig.integrity).toBe(true); + + expect(dts.isDocTimestamp).toBe(true); + expect(dts.subFilter).toBe('ETSI.RFC3161'); + expect(dts.integrity).toBe(true); // messageImprint == hash(/ByteRange) + expect(dts.signatureValid).toBe(true); // TSA token signature + expect(dts.timestampPresent).toBe(true); + expect(dts.timestampValid).toBe(true); + expect(dts.timestampTime).toBe('2026-02-01T12:00:00.000Z'); + expect(result.allValid).toBe(true); + + // A valid B-LTA document must pass --strict (exit 0 = no throw). + await captureStdout(() => verify(parseArgs(['--input', stamped, '--strict']))); + }); + + it('corrupted /DocTimeStamp byte range → imprint mismatch fails --strict with E_VERIFY_FAILED', async () => { + const stamped = await addDocTimestamp(await signBase(), ['--field-name', 'TsField1']); + + // Flip one byte INSIDE the timestamped revision but OUTSIDE any + // /Contents hex window: the last character of the /T (TsField1) + // field-name literal, which only the /DocTimeStamp byte range covers + // (the first signature's range ends before the appended revision). + const bytes = await fs.readFile(stamped); + const idx = bytes.indexOf(Buffer.from('TsField1', 'latin1')); + expect(idx).toBeGreaterThan(0); + bytes[idx + 7] = bytes[idx + 7]! ^ 0x01; // 'TsField1' → 'TsField0' + const corrupted = out('lta-corrupted.pdf'); + await fs.writeFile(corrupted, bytes); + + const result = await verifyJson(corrupted); + expect(result.signatures).toHaveLength(2); + const dts = result.signatures.find((s) => s.isDocTimestamp)!; + expect(dts.integrity).toBe(false); + expect(result.allValid).toBe(false); + // The original signature is untouched (its range predates the flip). + const docSig = result.signatures.find((s) => !s.isDocTimestamp)!; + expect(docSig.integrity).toBe(true); + expect(docSig.signatureValid).toBe(true); + + const err = await captureStdout(() => + verify(parseArgs(['--input', corrupted, '--strict'])), + ).catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).exitCode).toBe(1); + expect((err as CliError).code).toBe(ErrorCode.VERIFY_FAILED); + }); +}); diff --git a/tests/helpers/der.ts b/tests/helpers/der.ts new file mode 100644 index 0000000..64dd6d3 --- /dev/null +++ b/tests/helpers/der.ts @@ -0,0 +1,128 @@ +/** + * Minimal DER primitives for the offline mock PKI (tests/helpers/mock-pki.ts). + * + * pdfnative's public barrel exports the DER builders the LTV feature itself + * needs (derSequence, derSetOf, derOid, derInteger, derBitString, + * derOctetString, derGeneralizedTime, derDecode) — re-exported through the + * core-bridge. The extra builders required to assemble X.509 certificates and + * RFC 3161 / OCSP / CRL structures from scratch are internal to the library, + * so this file re-implements them locally (plain X.690 TLV encoding). + * + * TEST HELPER ONLY — never ships in dist. + */ + +import { createHash } from 'node:crypto'; +import type { Asn1Node } from '../../src/core-bridge/index.js'; + +// ── ASN.1 universal tags (subset used by the mock PKI) ─────────────── + +export const ASN1_BOOLEAN = 0x01; +export const ASN1_INTEGER = 0x02; +export const ASN1_OID = 0x06; +export const ASN1_SEQUENCE = 0x30; + +// ── Hashes (node:crypto — the CLI is Node-only) ────────────────────── + +export function sha256(input: Uint8Array): Uint8Array { + return new Uint8Array(createHash('sha256').update(input).digest()); +} + +export function sha384(input: Uint8Array): Uint8Array { + return new Uint8Array(createHash('sha384').update(input).digest()); +} + +export function sha512(input: Uint8Array): Uint8Array { + return new Uint8Array(createHash('sha512').update(input).digest()); +} + +// ── TLV encoding ───────────────────────────────────────────────────── + +function derLength(len: number): Uint8Array { + if (len < 0x80) return new Uint8Array([len]); + const bytes: number[] = []; + let v = len; + while (v > 0) { + bytes.unshift(v & 0xff); + v >>>= 8; + } + return new Uint8Array([0x80 | bytes.length, ...bytes]); +} + +function concat(arrays: readonly Uint8Array[]): Uint8Array { + let total = 0; + for (const a of arrays) total += a.length; + const out = new Uint8Array(total); + let offset = 0; + for (const a of arrays) { + out.set(a, offset); + offset += a.length; + } + return out; +} + +/** Encode a TLV with the given tag around already-encoded value bytes. */ +export function derWrap(tag: number, value: Uint8Array): Uint8Array { + return concat([new Uint8Array([tag]), derLength(value.length), value]); +} + +/** SET (0x31) preserving child order (DER SET OF sorting is derSetOf's job). */ +export function derSet(...children: Uint8Array[]): Uint8Array { + return derWrap(0x31, concat(children)); +} + +export function derNull(): Uint8Array { + return new Uint8Array([0x05, 0x00]); +} + +export function derBoolean(value: boolean): Uint8Array { + return new Uint8Array([0x01, 0x01, value ? 0xff : 0x00]); +} + +export function derUtf8String(text: string): Uint8Array { + return derWrap(0x0c, new TextEncoder().encode(text)); +} + +/** UTCTime — YYMMDDHHmmssZ (RFC 5280 §4.1.2.5.1). */ +export function derUtcTime(date: Date): Uint8Array { + const pad = (n: number): string => String(n).padStart(2, '0'); + const text = pad(date.getUTCFullYear() % 100) + + pad(date.getUTCMonth() + 1) + + pad(date.getUTCDate()) + + pad(date.getUTCHours()) + + pad(date.getUTCMinutes()) + + pad(date.getUTCSeconds()) + + 'Z'; + const bytes = new Uint8Array(text.length); + for (let i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i); + return derWrap(0x17, bytes); +} + +/** Context-specific explicit tag [n] — constructed. */ +export function derContextExplicit(tagNum: number, inner: Uint8Array): Uint8Array { + return derWrap(0xa0 | tagNum, inner); +} + +/** Context-specific implicit tag [n] — primitive. */ +export function derContextImplicit(tagNum: number, value: Uint8Array): Uint8Array { + return derWrap(0x80 | tagNum, value); +} + +// ── Node helpers (operate on pdfnative's Asn1Node) ─────────────────── + +/** Raw DER bytes of a decoded child TLV, sliced from the original buffer. */ +export function derRawBytes(buf: Uint8Array, node: Asn1Node): Uint8Array { + return buf.subarray(node.offset, node.offset + node.totalLength); +} + +/** INTEGER node → bigint (two's complement). */ +export function asn1Integer(node: Asn1Node): bigint { + if (node.tag !== ASN1_INTEGER) throw new Error(`Expected INTEGER, got tag 0x${node.tag.toString(16)}`); + const bytes = node.value; + if (bytes.length === 0) return 0n; + const isNeg = (bytes[0] & 0x80) !== 0; + let result = 0n; + for (const byte of bytes) { + result = (result << 8n) | BigInt(isNeg ? (~byte & 0xff) : byte); + } + return isNeg ? -(result + 1n) : result; +} diff --git a/tests/helpers/mock-pki.test.ts b/tests/helpers/mock-pki.test.ts new file mode 100644 index 0000000..58c7b9d --- /dev/null +++ b/tests/helpers/mock-pki.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { + ensureCryptoReady, buildTimestampRequest, parseTimestampResponse, + parseTimestampToken, verifyTimestampImprint, verifyCertSignature, isSelfSigned, +} from '../../src/core-bridge/index.js'; +import { createMockPki, createMockTimestampProvider, createMockRevocationProvider, MOCK_OCSP_URL, MOCK_CRL_URL } from './mock-pki.js'; +import { sha256 } from './der.js'; + +beforeAll(async () => { + await ensureCryptoReady(); +}); + +describe('createMockPki', () => { + it('should build a root-signed chain with TSA and OCSP leaves', () => { + const pki = createMockPki(); + expect(isSelfSigned(pki.rootCert)).toBe(true); + expect(verifyCertSignature(pki.signerCert, pki.rootCert)).toBe(true); + expect(verifyCertSignature(pki.tsaCert, pki.rootCert)).toBe(true); + expect(verifyCertSignature(pki.ocspCert, pki.rootCert)).toBe(true); + expect(pki.signerCert.ocspUrls).toContain(MOCK_OCSP_URL); + expect(pki.signerCert.crlUrls).toContain(MOCK_CRL_URL); + }); +}); + +describe('createMockTimestampProvider', () => { + it('should return a granted TimeStampResp that round-trips through the library parsers', async () => { + const pki = createMockPki(); + const provider = createMockTimestampProvider(pki); + const imprint = sha256(new Uint8Array([1, 2, 3])); + const request = buildTimestampRequest(imprint, { nonce: 42n }); + + const responseDer = await provider.getTimestamp(request); + const response = parseTimestampResponse(responseDer); + expect(response.status).toBe(0); + expect(response.token).toBeDefined(); + + const info = parseTimestampToken(response.token as Uint8Array); + expect(info.nonce).toBe(42n); + expect(info.genTime.toISOString()).toBe('2026-02-01T12:00:00.000Z'); + expect(verifyTimestampImprint(info, imprint)).toBe(true); + expect(info.tsaCertificates.length).toBeGreaterThan(0); + }); + + it('should return a rejection without token when status is forced', async () => { + const pki = createMockPki(); + const provider = createMockTimestampProvider(pki, { status: 2 }); + const request = buildTimestampRequest(sha256(new Uint8Array([9])), {}); + const response = parseTimestampResponse(await provider.getTimestamp(request)); + expect(response.status).toBe(2); + expect(response.token).toBeUndefined(); + }); +}); + +describe('createMockRevocationProvider', () => { + it('should serve a parseable CRL signed by the root', async () => { + const pki = createMockPki(); + const provider = createMockRevocationProvider(pki); + const crl = await provider.fetchCrl?.(MOCK_CRL_URL); + expect(crl).toBeInstanceOf(Uint8Array); + expect((crl as Uint8Array).length).toBeGreaterThan(100); + }); + + it('should list the signer serial in the CRL when revoked', async () => { + const pki = createMockPki(); + const provider = createMockRevocationProvider(pki, { revoked: true }); + const fresh = await provider.fetchCrl?.(MOCK_CRL_URL); + const clean = await createMockRevocationProvider(pki).fetchCrl?.(MOCK_CRL_URL); + expect((fresh as Uint8Array).length).toBeGreaterThan((clean as Uint8Array).length); + }); +}); diff --git a/tests/helpers/mock-pki.ts b/tests/helpers/mock-pki.ts new file mode 100644 index 0000000..d1a7b9d --- /dev/null +++ b/tests/helpers/mock-pki.ts @@ -0,0 +1,491 @@ +/** + * Mock PKI for offline LTV testing — deterministic root/signer/TSA/OCSP + * certificates plus in-process RFC 3161 and OCSP/CRL providers. + * + * Faithful port of pdfnative's own test helper + * (scripts/helpers/mock-pki.ts upstream — not published to npm), built on + * the public exports re-exported through src/core-bridge plus the local + * DER primitives in tests/helpers/der.ts: real DER, real RSA signatures, + * fixed pre-generated keys — zero network, zero binary fixtures, fully + * reproducible. The providers assemble *genuine* TimeStampToken / + * BasicOCSPResponse / CertificateList structures so tests can round-trip + * them through the library's own parsers. + * + * FOR TESTS AND SAMPLES ONLY — the keys below are public by definition. + */ + +import { + derSequence, derSetOf, derOid, derInteger, derBitString, derOctetString, + derGeneralizedTime, derDecode, sha1, rsaSignHash, parseCertificate, +} from '../../src/core-bridge/index.js'; +import type { + RsaPrivateKey, RsaDigest, X509Certificate, TimestampProvider, RevocationProvider, +} from '../../src/core-bridge/index.js'; +import { + derSet, derNull, derBoolean, derUtf8String, derUtcTime, + derContextExplicit, derContextImplicit, derWrap, derRawBytes, asn1Integer, + sha256, sha384, sha512, + ASN1_SEQUENCE, ASN1_INTEGER, ASN1_OID, ASN1_BOOLEAN, +} from './der.js'; + +// ── Deterministic key material (RSA-2048, pre-generated — DEMO ONLY) ─ + +const ROOT_KEY: RsaPrivateKey = { + n: BigInt('0x9AE8E641BB96D39923C20545F2D0DBCA0F3355D5B0BAEC6EC4AE2C2831EC2B7D65B5ACECABFFC798268B32A2A5680235327972FF31EC444303B4340C21F724C3578CFBE86C094C38D51835B121938194A7B6E3ECF963B03AB81FEF190BE9D9908C7908A70B61E8701797E361FA165BC882113EE13B6E7B6491AB262130FE07804E5E689956BEA6402CFD6A2984AABF74DCE7B24668696493DDB26FADB96AB268B1196FFB4EF148835B935D787D618BEB61A8D8E98792EE27E6A7ABF9D47A758792B10B5DEB6031653119ED2280EB19387D51DEAC14BE7C4CC7D059B964B0707A1E97DCC8380DE669D58D1536AAF69702F7C818893BA04300A6E96DEF3015CE9D'), + d: BigInt('0x87CA304CDE8B4FE0A59FA5CFB311B218654AB2AB26C83408C50F885593AD5A52099E3F7F17269767A021D4B90E15261A4BAC7A4989117AA4E3D24AED77B894D9471DA6940E5FF818B973075DC5F8EF55C7CE61ED908EFA23FED2BF5C4D3D2821B581433C6A95E092A19BDB0E3F92E9D1D1721C9482EC4DEDD2631C816BD8C1E9317FA5C58FD02582C7A64174F4FB33B33A5089057E3AF54C6811AF9F52270CDE2AA26F8BC2965FE4652515F1ACB5F6CCD7EA620F68A313463CB864DC199FFA9DA11846C26CCFC9D5F8102E8EC18CDC1D37A712983EA62E52B367002B364B757ADD390EDAD5021B551A2A43B1A8581464BF34E2A871369F48DA975F1CA2FD7D7'), + p: BigInt('0xCD41C7BFE14222AF3B927AF5869A7840B93E1827FD6A34B1C1305B7F37C0696BF1AFCCA95573C1EC1C8981D95CDB5FDE6FECF6A804D696602D75228C028077A759FCB45719F025532E460E7D2D44DF43869465F135B115E026B8A9179E41E9FAFFEA088C98204E1084491C6B7E0F1AE3AA6753DE2AA1C220AD6DD71E2A69643B'), + q: BigInt('0xC134C430CFB0AE676C82E2D17FDCF9AC375307A4AD845BBC311F426A2D136B9470690ECFD80A9DADEEBFBA82340FCCB60023CB2EBF38D980DA1BADAA256A0504360A4196D3A35A5DEFB1273291F2E22DA144D2BF924E42A0D00917E628704161205F6AD5D3EB3D9FC5B3354F2B01CF0EB589F88CE3B929A480AA485573802307'), + dp: BigInt('0x773512FB9FA9B7572A34027182414841DED3EF579A580A4E8A32B99103221E97F07FF74F092FF79A57608D275E4492432FA1E206E6F871D15DD53FC12CDACBA34821F9E2F44F827DF2CC0132360E5FD469DED9EDE30EFBF378C99A7AFB10B101738BCA774D0AC60BD5A858771D794C5698EAF5CC7BDA0252D4268CDF1A26A76D'), + dq: BigInt('0x96CAE62CEA8C8D322F60E0427EB72B2E9C677359B60BCDC54DD985EE748BE9B50B1F13EE6841B0DB65B1C29188ACA42B90645F5A76E899E9955170F3910BF42A5B3B1A01EBE05CD1601835EDA4379B0BDE08672C19B6770E281050D4D3CEF95822FA15DA19F24A407EFEE5A68A7C78EC9407C133C530692DF935EE0AB380D8D5'), + qi: BigInt('0x927A0F6D39CFBCAFE8B5123A8B7D719EBA3FECEB7F96296A72C0A47F35364ACCC044237064938A7E9CC04F2849DD0E8C8AD8BE1061E6E57A9FCEA82A0E25D2F12AC3BEC573B12C7FD5DDF510216E3943820A5DD32B3E5F05ACFDC566AAB1836B9E14B81BC557E04FB6EA1C454CDCF3DC377BBB08E26BBD24B2D08A286413207C'), +}; + +const LEAF_KEY: RsaPrivateKey = { + n: BigInt('0xC931B6D31ADB9B544B083B67FF539C5A9A37BB09E89A8D6326271CBDD658C4F3A0A2FF93E4BD33A3934C07BD15C11E21A16F57909C8E53B8570CB3A4D3BFFDEDFE7C1D87D310B4EBA719FD1B7D7CFD8895660228B0D6573681702EDAEDAAA434F1D4F11FD88F3953F32383F8AC861DB6552A42F13E0D95241310814CC06A203E12154AE610CF599A051B37F9E53110A6DDF76DC7195AE7CF582DE5077C9D6AB86102B4C5584B69826F53307E186C4701BB948BDE6678434082D71209FD7663D7CB24DFE5D721C0FB4702A183D42BA7A25E55E645147177AD5E79EE2D794D292BF797004C9C5FE357AAA782075395180C6E265EC9D981A61FF4237B8157BFC3F7'), + d: BigInt('0x16C3139A8D9AD3D77507B0E4E2AB9C7059AB3D737107EF400CE476E9795320B12976D7706D7C045818C58CC89529FC953474EAD4B709992A2D5B0805F958EACB1EB3FF094B55440AB7248710B1A14EF3C167418E45F771F57E2E6976A655DC6F0AFF1AEA8DEF223EBFAEC073055DC505C85BD827FFEBBC7850D9D8AF0CC3D4F2CD5D4F33C9116C13542BCFA289F44C0499180E98443D635E3E282039245A7FB0766078C5CB11640BD4DE9B86FA889D6FBED99A7D1B8B883153A09EBD4626713291F9A5379D4AFB55B46402EF93FD2603EB89BECF451E6DD06336FF72C62F6EB3CC9D5E91D59912DAC2A73C303044B2627B0CC287A80AB22C66DFCC6F89C159C5'), + p: BigInt('0xEFF7015A1E22E1E844AB9CD0BDA635BD6EA47D802AE336A2908DA1426045D25077E40588C374C2FDE3E4271CB694B3AACD25B8369985D24C95B9817A8FCEBD02A47A1D116CD407628B7FDC13609B339A8A8579A26FCA856164E280580A0B3FB4FC9D21486B53968513982A38AD59EFEE3B882710F176C2DFD1F6939F9E20F80B'), + q: BigInt('0xD6A379002C6E1B60BE3442CFBE6E48F858BCEE058B0F240AA1BCBC805D6BABD88D6F937358BCEC87DCB6569D3B87FDF24218CEF843B297BB41A812F8E09F4453AB470ED087D68D0411A5E88CD16227087B4807EB72190B063F2F3D053FF2CC204A1F8D0AD9794061A28A50F062378B19BEFAA053158099911020CF8753B35B45'), + dp: BigInt('0xB946C4D378DB44039B29C9CD5DF0BC23840F1B1B5F81C988610609917F55C999F9C7A40241AFA07279878A2F604596277577FF30A0FEB32E109887814311C3DC0B74818717B8E9EECB78B04A81D7B3534A4ADE6C6DD6377FC86E1DDC5BFCED76676946EE6C77C08B0563028E7A422BBF8C55869C4D637DF9645AF7065208709B'), + dq: BigInt('0xCB652E0CD40DA334120A4425C937893E8E18BB15D5A90B6667CE0A733A14064CB7FABBA7DAB76D0D7241F7E217BFCF0DFB44B71CDC4A292EF210EBA99C7250B558E1855066E911C88150CF066284B8A878EAD1567450F6F97C76AF44824CFAD2BE6B17A4E860D679AF25937DB8151A63D36E7CEF3EB916CD38935F15C6637861'), + qi: BigInt('0x89AE4FE4144A3C4B848F0CC9660FEA368C630C758CEFA8E315198028CFCD2A75109BAABCEC5546FF2A16BF740256C56EE4928015B6359D964988B9350922AF28FAA82609E9B31F144F04FC287B30DBD7D7BC7A614C2B67D9C101BB1835149CA7E627AF7491083A755C1999C0714FD192DD79CFA562F5428B8861019E5BFD8AAB'), +}; + +const RSA_E = 65537n; + +// ── Well-known OIDs ────────────────────────────────────────────────── + +const OID_CN = new Uint8Array([0x55, 0x04, 0x03]); +const OID_RSA_ENCRYPTION = new Uint8Array([0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01]); +const OID_SHA256_RSA = new Uint8Array([0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x0b]); +const OID_SHA384_RSA = new Uint8Array([0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x0c]); +const OID_SHA512_RSA = new Uint8Array([0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x0d]); +const OID_SHA256 = new Uint8Array([0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01]); + +const OID_BASIC_CONSTRAINTS = new Uint8Array([0x55, 0x1d, 0x13]); +const OID_SUBJECT_KEY_ID = new Uint8Array([0x55, 0x1d, 0x0e]); +const OID_AUTHORITY_KEY_ID = new Uint8Array([0x55, 0x1d, 0x23]); +const OID_EXT_KEY_USAGE = new Uint8Array([0x55, 0x1d, 0x25]); +const OID_CRL_DISTRIBUTION_POINTS = new Uint8Array([0x55, 0x1d, 0x1f]); +const OID_AUTHORITY_INFO_ACCESS = new Uint8Array([0x2b, 0x06, 0x01, 0x05, 0x05, 0x07, 0x01, 0x01]); +const OID_AD_OCSP = new Uint8Array([0x2b, 0x06, 0x01, 0x05, 0x05, 0x07, 0x30, 0x01]); +const OID_AD_CA_ISSUERS = new Uint8Array([0x2b, 0x06, 0x01, 0x05, 0x05, 0x07, 0x30, 0x02]); +const OID_OCSP_NOCHECK = new Uint8Array([0x2b, 0x06, 0x01, 0x05, 0x05, 0x07, 0x30, 0x01, 0x05]); +const OID_OCSP_BASIC = new Uint8Array([0x2b, 0x06, 0x01, 0x05, 0x05, 0x07, 0x30, 0x01, 0x01]); + +/** id-kp-timeStamping — 1.3.6.1.5.5.7.3.8 */ +export const OID_KP_TIME_STAMPING = new Uint8Array([0x2b, 0x06, 0x01, 0x05, 0x05, 0x07, 0x03, 0x08]); +/** id-kp-OCSPSigning — 1.3.6.1.5.5.7.3.9 */ +export const OID_KP_OCSP_SIGNING = new Uint8Array([0x2b, 0x06, 0x01, 0x05, 0x05, 0x07, 0x03, 0x09]); + +const OID_SIGNED_DATA = new Uint8Array([0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x02]); +const OID_CONTENT_TYPE = new Uint8Array([0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x09, 0x03]); +const OID_MESSAGE_DIGEST = new Uint8Array([0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x09, 0x04]); +const OID_CT_TST_INFO = new Uint8Array([0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x09, 0x10, 0x01, 0x04]); +/** Arbitrary mock TSA policy — 1.2.3.4. */ +export const MOCK_TSA_POLICY_OID = new Uint8Array([0x2a, 0x03, 0x04]); + +// ── Mock URLs (.invalid TLD per RFC 2606 — can never resolve) ──────── + +export const MOCK_OCSP_URL = 'http://mock.invalid/ocsp'; +export const MOCK_CA_ISSUERS_URL = 'http://mock.invalid/ca.der'; +export const MOCK_CRL_URL = 'http://mock.invalid/crl.der'; + +// ── Types ──────────────────────────────────────────────────────────── + +export interface MockPki { + readonly rootCert: X509Certificate; + readonly rootKey: RsaPrivateKey; + readonly signerCert: X509Certificate; + readonly signerKey: RsaPrivateKey; + readonly tsaCert: X509Certificate; + readonly tsaKey: RsaPrivateKey; + readonly ocspCert: X509Certificate; + readonly ocspKey: RsaPrivateKey; +} + +export interface MockCertificateOptions { + readonly subjectCn: string; + /** Issuer name DER + key; omit for self-signed. */ + readonly issuerName?: Uint8Array; + readonly issuerKey?: RsaPrivateKey; + readonly serialNumber?: bigint; + readonly subjectKey?: RsaPrivateKey; + /** Certificate signature digest (default 'sha256'). */ + readonly digest?: RsaDigest; + readonly isCa?: boolean; + readonly eku?: { readonly oids: readonly Uint8Array[]; readonly critical?: boolean }; + readonly ocspUrl?: string; + readonly caIssuersUrl?: string; + readonly crlUrl?: string; + readonly ocspNoCheck?: boolean; + /** AKI keyIdentifier (typically the issuer's SKI). */ + readonly authorityKeyId?: Uint8Array; + readonly notBefore?: Date; + readonly notAfter?: Date; +} + +// ── Certificate builder ────────────────────────────────────────────── + +function ia5Bytes(text: string): Uint8Array { + const bytes = new Uint8Array(text.length); + for (let i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i); + return bytes; +} + +function extension(oid: Uint8Array, valueDer: Uint8Array, critical = false): Uint8Array { + const parts: Uint8Array[] = [derOid(oid)]; + if (critical) parts.push(derBoolean(true)); + parts.push(derOctetString(valueDer)); + return derSequence(...parts); +} + +function sigAlgFor(digest: RsaDigest): { readonly der: Uint8Array; readonly hash: (input: Uint8Array) => Uint8Array } { + const oid = digest === 'sha256' ? OID_SHA256_RSA : digest === 'sha384' ? OID_SHA384_RSA : OID_SHA512_RSA; + const hash = digest === 'sha256' ? sha256 : digest === 'sha384' ? sha384 : sha512; + return { der: derSequence(derOid(oid), derNull()), hash }; +} + +/** SKI = SHA-1 of the RSAPublicKey DER — RFC 5280 §4.2.1.2 method (1)-style. */ +function keyIdentifier(key: RsaPrivateKey): Uint8Array { + return sha1(derSequence(derInteger(key.n), derInteger(RSA_E))); +} + +/** + * Build a real DER X.509 v3 certificate and round-trip it through + * `parseCertificate()` so every extension field is populated by the + * library's own parser. + */ +export function buildMockCertificate(options: MockCertificateOptions): X509Certificate { + const subjectKey = options.subjectKey ?? LEAF_KEY; + const issuerKey = options.issuerKey ?? subjectKey; + const digest = options.digest ?? 'sha256'; + const { der: sigAlgDer, hash } = sigAlgFor(digest); + + const subjectName = derSequence(derSet(derSequence(derOid(OID_CN), derUtf8String(options.subjectCn)))); + const issuerName = options.issuerName ?? subjectName; + + const notBefore = options.notBefore ?? new Date('2024-01-01T00:00:00Z'); + const notAfter = options.notAfter ?? new Date('2044-01-01T00:00:00Z'); + const validity = derSequence(derUtcTime(notBefore), derUtcTime(notAfter)); + + const rsaPubKeyDer = derSequence(derInteger(subjectKey.n), derInteger(RSA_E)); + const spki = derSequence( + derSequence(derOid(OID_RSA_ENCRYPTION), derNull()), + derBitString(rsaPubKeyDer), + ); + + // ── Extensions ─────────────────────────────────────────────── + const exts: Uint8Array[] = []; + if (options.isCa) { + exts.push(extension(OID_BASIC_CONSTRAINTS, derSequence(derBoolean(true)), true)); + } + exts.push(extension(OID_SUBJECT_KEY_ID, derOctetString(keyIdentifier(subjectKey)))); + if (options.authorityKeyId) { + exts.push(extension(OID_AUTHORITY_KEY_ID, derSequence(derContextImplicit(0, options.authorityKeyId)))); + } + if (options.eku) { + exts.push(extension( + OID_EXT_KEY_USAGE, + derSequence(...options.eku.oids.map((oid) => derOid(oid))), + options.eku.critical ?? false, + )); + } + if (options.ocspUrl !== undefined || options.caIssuersUrl !== undefined) { + const descriptions: Uint8Array[] = []; + if (options.ocspUrl !== undefined) { + descriptions.push(derSequence(derOid(OID_AD_OCSP), derContextImplicit(6, ia5Bytes(options.ocspUrl)))); + } + if (options.caIssuersUrl !== undefined) { + descriptions.push(derSequence(derOid(OID_AD_CA_ISSUERS), derContextImplicit(6, ia5Bytes(options.caIssuersUrl)))); + } + exts.push(extension(OID_AUTHORITY_INFO_ACCESS, derSequence(...descriptions))); + } + if (options.crlUrl !== undefined) { + // DistributionPoint { [0] DistributionPointName { [0] fullName GeneralNames { [6] URI } } } + const dp = derSequence(derWrap(0xa0, derWrap(0xa0, derContextImplicit(6, ia5Bytes(options.crlUrl))))); + exts.push(extension(OID_CRL_DISTRIBUTION_POINTS, derSequence(dp))); + } + if (options.ocspNoCheck) { + exts.push(extension(OID_OCSP_NOCHECK, derNull())); + } + + const tbs = derSequence( + derContextExplicit(0, derInteger(2n)), // version v3 + derInteger(options.serialNumber ?? 1n), + sigAlgDer, + issuerName, + validity, + subjectName, + spki, + derContextExplicit(3, derSequence(...exts)), + ); + + const signatureBytes = rsaSignHash(hash(tbs), issuerKey, digest); + const certDer = derSequence(tbs, sigAlgDer, derBitString(signatureBytes)); + return parseCertificate(certDer); +} + +// ── PKI factory ────────────────────────────────────────────────────── + +let _pki: MockPki | undefined; + +/** + * Deterministic four-certificate PKI: self-signed root CA, a signer + * certificate with AIA/CRL DP pointing at `http://mock.invalid/...`, a + * TSA certificate (EKU id-kp-timeStamping, critical) and an OCSP responder + * certificate (EKU id-kp-OCSPSigning + id-pkix-ocsp-nocheck). The three + * leaves share one key pair (LEAF_KEY) — irrelevant for structure tests + * and it keeps the fixture small. Memoised: inputs are constants. + */ +export function createMockPki(): MockPki { + if (_pki) return _pki; + + const rootCert = buildMockCertificate({ + subjectCn: 'pdfnative Mock Root CA', + subjectKey: ROOT_KEY, + serialNumber: 1n, + isCa: true, + }); + const rootName = rootCert.subject.raw; + const rootKeyId = rootCert.subjectKeyId; + + const signerCert = buildMockCertificate({ + subjectCn: 'pdfnative Mock Signer', + subjectKey: LEAF_KEY, + issuerName: rootName, + issuerKey: ROOT_KEY, + serialNumber: 2n, + authorityKeyId: rootKeyId, + ocspUrl: MOCK_OCSP_URL, + caIssuersUrl: MOCK_CA_ISSUERS_URL, + crlUrl: MOCK_CRL_URL, + }); + + const tsaCert = buildMockCertificate({ + subjectCn: 'pdfnative Mock TSA', + subjectKey: LEAF_KEY, + issuerName: rootName, + issuerKey: ROOT_KEY, + serialNumber: 3n, + authorityKeyId: rootKeyId, + eku: { oids: [OID_KP_TIME_STAMPING], critical: true }, + }); + + const ocspCert = buildMockCertificate({ + subjectCn: 'pdfnative Mock OCSP Responder', + subjectKey: LEAF_KEY, + issuerName: rootName, + issuerKey: ROOT_KEY, + serialNumber: 4n, + authorityKeyId: rootKeyId, + eku: { oids: [OID_KP_OCSP_SIGNING] }, + ocspNoCheck: true, + }); + + _pki = { + rootCert, rootKey: ROOT_KEY, + signerCert, signerKey: LEAF_KEY, + tsaCert, tsaKey: LEAF_KEY, + ocspCert, ocspKey: LEAF_KEY, + }; + return _pki; +} + +/** PEM-encode the mock signer key/cert for CLI flags (--key/--cert). */ +export function toPem(label: 'CERTIFICATE' | 'RSA PRIVATE KEY', der: Uint8Array): string { + const b64 = Buffer.from(der).toString('base64'); + const lines = b64.match(/.{1,64}/g) ?? []; + return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`; +} + +// ── Mock RFC 3161 TSA ──────────────────────────────────────────────── + +export interface MockTimestampOptions { + /** Force a PKIStatus (e.g. 2 = rejection). Default 0 (granted). */ + readonly status?: number; + /** TSTInfo genTime (default 2026-02-01T12:00:00Z — deterministic). */ + readonly genTime?: Date; + /** TSTInfo serialNumber (default 0x1234). */ + readonly serialNumber?: bigint; +} + +/** + * In-process RFC 3161 TSA: parses the TimeStampReq, echoes its + * messageImprint and nonce, and returns a TimeStampResp containing a real + * TimeStampToken — a SignedData over the TSTInfo, signed with the mock TSA + * key, TSA certificate embedded. With `status` ≠ 0 it returns a rejection + * without a token. + */ +export function createMockTimestampProvider(pki: MockPki, options?: MockTimestampOptions): TimestampProvider { + return { + getTimestamp(request: Uint8Array): Promise { + const status = options?.status ?? 0; + if (status !== 0) { + const statusInfo = derSequence( + derInteger(BigInt(status)), + derSequence(derUtf8String('rejected by mock TSA')), + ); + return Promise.resolve(derSequence(statusInfo)); + } + + // TimeStampReq ::= SEQUENCE { version, messageImprint, + // reqPolicy?, nonce?, certReq? } + const req = derDecode(request); + if (req.tag !== ASN1_SEQUENCE || req.children.length < 2) { + throw new Error('mock TSA: malformed TimeStampReq'); + } + const imprintRaw = derRawBytes(request, req.children[1]); + let nonce: bigint | undefined; + let certReq = false; + for (let i = 2; i < req.children.length; i++) { + const child = req.children[i]; + if (child.tag === ASN1_INTEGER) nonce = asn1Integer(child); + else if (child.tag === ASN1_BOOLEAN) certReq = child.value[0] !== 0; + else if (child.tag !== ASN1_OID) break; + } + + // ── TSTInfo ────────────────────────────────────────── + const tstFields: Uint8Array[] = [ + derInteger(1n), + derOid(MOCK_TSA_POLICY_OID), + imprintRaw, // echo verbatim + derInteger(options?.serialNumber ?? 0x1234n), + derGeneralizedTime(options?.genTime ?? new Date('2026-02-01T12:00:00Z')), + ]; + if (nonce !== undefined) tstFields.push(derInteger(nonce)); + const tstInfo = derSequence(...tstFields); + + // ── SignedData over the TSTInfo ────────────────────── + const digestAlgId = derSequence(derOid(OID_SHA256), derNull()); + const encap = derSequence( + derOid(OID_CT_TST_INFO), + derContextExplicit(0, derOctetString(tstInfo)), + ); + const signedAttrs = derSetOf( + derSequence(derOid(OID_CONTENT_TYPE), derSet(derOid(OID_CT_TST_INFO))), + derSequence(derOid(OID_MESSAGE_DIGEST), derSet(derOctetString(sha256(tstInfo)))), + ); + const signedAttrsImplicit = new Uint8Array(signedAttrs); + signedAttrsImplicit[0] = 0xa0; + const signature = rsaSignHash(sha256(signedAttrs), pki.tsaKey); + + const signerInfo = derSequence( + derInteger(1n), + derSequence(pki.tsaCert.issuer.raw, derInteger(pki.tsaCert.serialNumber)), + digestAlgId, + signedAttrsImplicit, + derSequence(derOid(OID_SHA256_RSA), derNull()), + derOctetString(signature), + ); + + const signedDataFields: Uint8Array[] = [ + derInteger(3n), // version 3 (eContentType ≠ id-data) + derSet(digestAlgId), + encap, + ]; + if (certReq) signedDataFields.push(derWrap(0xa0, pki.tsaCert.raw)); + signedDataFields.push(derSet(signerInfo)); + + const token = derSequence( + derOid(OID_SIGNED_DATA), + derContextExplicit(0, derSequence(...signedDataFields)), + ); + + const statusInfo = derSequence(derInteger(0n)); + return Promise.resolve(derSequence(statusInfo, token)); + }, + }; +} + +// ── Mock OCSP responder + CRL distribution point ───────────────────── + +export interface MockRevocationOptions { + /** Report the certificate as revoked (default false = good). */ + readonly revoked?: boolean; + /** Serve validity windows that already expired (default false). */ + readonly staleNextUpdate?: boolean; +} + +/** + * In-process revocation provider serving a real signed BasicOCSPResponse + * (signed by the mock OCSP responder key, responder certificate embedded, + * CertID echoed from the request) and a real signed CertificateList + * (signed by the root). Deterministic dates: + * - fresh: thisUpdate 2026-01-01, nextUpdate 2036-01-01 + * - stale: thisUpdate 2019-12-01, nextUpdate 2020-01-01 + * With `revoked: true` the OCSP status is revoked (2025-06-01) and the CRL + * lists the mock signer certificate's serial. + */ +export function createMockRevocationProvider(pki: MockPki, options?: MockRevocationOptions): RevocationProvider { + const stale = options?.staleNextUpdate ?? false; + const revoked = options?.revoked ?? false; + const thisUpdate = stale ? new Date('2019-12-01T00:00:00Z') : new Date('2026-01-01T00:00:00Z'); + const nextUpdate = stale ? new Date('2020-01-01T00:00:00Z') : new Date('2036-01-01T00:00:00Z'); + const revocationTime = new Date('2025-06-01T00:00:00Z'); + const sigAlgId = derSequence(derOid(OID_SHA256_RSA), derNull()); + + return { + fetchOcsp(_url: string, request: Uint8Array): Promise { + // OCSPRequest → tbsRequest → requestList → Request → CertID + const req = derDecode(request); + const certIdNode = req.children[0]?.children[0]?.children[0]?.children[0]; + if (certIdNode === undefined || certIdNode.tag !== ASN1_SEQUENCE) { + throw new Error('mock OCSP: malformed OCSPRequest'); + } + const certIdRaw = derRawBytes(request, certIdNode); + + const certStatus = revoked + ? derWrap(0xa1, derGeneralizedTime(revocationTime)) // [1] RevokedInfo + : new Uint8Array([0x80, 0x00]); // [0] IMPLICIT NULL = good + + const single = derSequence( + certIdRaw, + certStatus, + derGeneralizedTime(thisUpdate), + derContextExplicit(0, derGeneralizedTime(nextUpdate)), + ); + + const tbsResponseData = derSequence( + derContextExplicit(1, pki.ocspCert.subject.raw), // responderID byName + derGeneralizedTime(thisUpdate), // producedAt + derSequence(single), + ); + + const basic = derSequence( + tbsResponseData, + sigAlgId, + derBitString(rsaSignHash(sha256(tbsResponseData), pki.ocspKey)), + derContextExplicit(0, derSequence(pki.ocspCert.raw)), + ); + + const responseBytes = derSequence(derOid(OID_OCSP_BASIC), derOctetString(basic)); + const response = derSequence( + derWrap(0x0a, new Uint8Array([0x00])), // ENUMERATED successful + derContextExplicit(0, responseBytes), + ); + return Promise.resolve(response); + }, + + fetchCrl(_url: string): Promise { + const tbsFields: Uint8Array[] = [ + derInteger(1n), // version v2 + sigAlgId, + pki.rootCert.subject.raw, // issuer + derUtcTime(thisUpdate), + derUtcTime(nextUpdate), + ]; + if (revoked) { + tbsFields.push(derSequence( + derSequence(derInteger(pki.signerCert.serialNumber), derUtcTime(revocationTime)), + )); + } + const tbsCertList = derSequence(...tbsFields); + const crl = derSequence( + tbsCertList, + sigAlgId, + derBitString(rsaSignHash(sha256(tbsCertList), pki.rootKey)), + ); + return Promise.resolve(crl); + }, + }; +} diff --git a/tests/integration/sign-verify-roundtrip.test.ts b/tests/integration/sign-verify-roundtrip.test.ts index a38b941..c8dae3b 100644 --- a/tests/integration/sign-verify-roundtrip.test.ts +++ b/tests/integration/sign-verify-roundtrip.test.ts @@ -20,6 +20,8 @@ import { render } from '../../src/commands/render.js'; import { sign } from '../../src/commands/sign.js'; import { verify } from '../../src/commands/verify.js'; import { parseArgs } from '../../src/utils/args.js'; +import { setTimestampProvider } from '../../src/core-bridge/index.js'; +import { createMockPki, createMockTimestampProvider } from '../helpers/mock-pki.js'; const FIXTURES = path.dirname(fileURLToPath(import.meta.url)); const RSA_KEY = path.join(FIXTURES, '..', 'fixtures', 'rsa-key.pem'); @@ -34,6 +36,9 @@ const minimalParams = JSON.stringify({ interface VerifyOutput { readonly signatures: ReadonlyArray<{ + readonly fieldName: string | null; + readonly subFilter: string | null; + readonly isDocTimestamp: boolean; readonly integrity: boolean; readonly chainValid: boolean; readonly trustedRoot: boolean; @@ -86,6 +91,7 @@ describe('sign → verify round-trip', () => { async function signWith( algorithm: 'rsa-sha256' | 'ecdsa-sha256', pureCrypto = false, + extraFlags: readonly string[] = [], ): Promise { const src = await renderUnsigned(); const out = path.join(os.tmpdir(), `rt-signed-${Date.now()}-${Math.random()}.pdf`); @@ -98,6 +104,7 @@ describe('sign → verify round-trip', () => { '--key', key, '--cert', cert, '--algorithm', algorithm, + ...extraFlags, ]; if (pureCrypto) argv.push('--pure-crypto'); await sign(parseArgs(argv)); @@ -155,6 +162,76 @@ describe('sign → verify round-trip', () => { expect(pure.signatures[0]!.signatureValid).toBe(true); }); + it('RSA + --timestamp (mock TSA): signs PAdES B-T style and verify reports the timestamp', async () => { + // Offline RFC 3161: the injected global provider beats the CLI's HTTP + // transport, so the .invalid URL is never contacted. + setTimestampProvider(createMockTimestampProvider(createMockPki())); + try { + const src = await renderUnsigned(); + const out = path.join(os.tmpdir(), `rt-ts-signed-${Date.now()}-${Math.random()}.pdf`); + tmpFiles.push(out); + await sign(parseArgs([ + '--input', src, + '--output', out, + '--key', RSA_KEY, + '--cert', RSA_CERT, + '--timestamp', 'http://tsa.mock.invalid/tsr', + ])); + + const result = await verifyJson(out); + expect(result.signatures).toHaveLength(1); + const sig = result.signatures[0]!; + expect(sig.integrity).toBe(true); + expect(sig.signatureValid).toBe(true); + expect(sig.timestampPresent).toBe(true); + expect(result.allValid).toBe(true); + } finally { + setTimestampProvider(null); + } + }); + + it('RSA + --digest sha384: signs and verifies as rsa-sha384 (v1.4.0)', async () => { + const signed = await signWith('rsa-sha256', false, ['--digest', 'sha384']); + const result = await verifyJson(signed); + expect(result.signatures).toHaveLength(1); + const sig = result.signatures[0]!; + expect(sig.integrity).toBe(true); + expect(sig.signatureValid).toBe(true); + expect(sig.signatureAlgorithm).toBe('rsa-sha384'); + expect(result.allValid).toBe(true); + }); + + it('RSA + --digest sha512: signs and verifies as rsa-sha512 (v1.4.0)', async () => { + const signed = await signWith('rsa-sha256', false, ['--digest', 'sha512']); + const result = await verifyJson(signed); + expect(result.signatures).toHaveLength(1); + const sig = result.signatures[0]!; + expect(sig.integrity).toBe(true); + expect(sig.signatureValid).toBe(true); + expect(sig.signatureAlgorithm).toBe('rsa-sha512'); + expect(result.allValid).toBe(true); + }); + + it('--pure-crypto + --digest sha384 also round-trips (pure-JS RSA path)', async () => { + const signed = await signWith('rsa-sha256', true, ['--digest', 'sha384']); + const result = await verifyJson(signed); + expect(result.signatures[0]!.signatureAlgorithm).toBe('rsa-sha384'); + expect(result.signatures[0]!.signatureValid).toBe(true); + expect(result.allValid).toBe(true); + }); + + it('--profile pades: ETSI.CAdES.detached signature verifies as fully valid (v1.4.0)', async () => { + const signed = await signWith('rsa-sha256', false, ['--profile', 'pades']); + const result = await verifyJson(signed); + expect(result.signatures).toHaveLength(1); + const sig = result.signatures[0]!; + expect(sig.subFilter).toBe('ETSI.CAdES.detached'); + expect(sig.isDocTimestamp).toBe(false); + expect(sig.integrity).toBe(true); + expect(sig.signatureValid).toBe(true); + expect(result.allValid).toBe(true); + }); + it('detects tampering (RSA): integrity FAIL after byte mutation', async () => { const signed = await signWith('rsa-sha256'); const bytes = await fs.readFile(signed); diff --git a/vitest.config.ts b/vitest.config.ts index 0e5a671..252e05c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -25,6 +25,10 @@ export default defineConfig({ // - timestamp-verify.ts a real RFC 3161 TSA token // - fetch-guard.ts a reachable PUBLIC host (loopback is // blocked by the SSRF guard by design) + // - tsa.ts real-TSA transport on fetch-guard; tests + // inject a mock TimestampProvider instead + // - ltv-provider.ts real OCSP/CRL transport on fetch-guard; + // tests inject a mock RevocationProvider exclude: [ 'src/index.ts', 'src/commands/verify.ts', @@ -32,6 +36,8 @@ export default defineConfig({ 'src/utils/revocation.ts', 'src/utils/timestamp-verify.ts', 'src/utils/fetch-guard.ts', + 'src/utils/tsa.ts', + 'src/utils/ltv-provider.ts', ], thresholds: { // Thresholds reflect unit coverage for the directly testable