DEV Community

DarkveilCorvyn26
DarkveilCorvyn26

Posted on

Node.js Document Previews — Signed Watermark Evidence Beyond the First PDF Page

Generate the first-page thumbnail only after the external-share watermark has been applied, then bind that thumbnail to the exact output PDF with a content digest and a signed audit record. The deciding constraint is evidentiary: a fast image that cannot be traced to the bytes a recipient received is decoration, not proof.

TL;DR: Put PDF validation, watermarking, first-page rasterization, and signing in one asynchronous publication pipeline. Let Express serve immutable thumbnails by artifact digest, keep signatures and keys outside the image, and page on broken publication invariants rather than cache misses.

How should Node.js convert the first PDF page to an image?

A document-sharing system has at least three related objects: the source PDF, the watermarked distribution PDF, and the thumbnail. Treating the thumbnail as an incidental derivative creates an ugly failure mode. A retry can watermark a new output while an old preview remains cached under the document ID; the UI then shows one recipient name or classification while the downloaded file contains another. The pixels look plausible, so a dashboard full of green request rates says nothing useful.

The invariant should be blunt: one published share version maps to one output digest, one watermark policy revision, one first-page thumbnail digest, and one audit statement. If any member is missing, the version is not publishable.

No half-state.

This also defines the page. Alert when a published version references an absent object, when recomputing an object's digest disagrees with its audit statement, or when signature verification fails. A thumbnail cache miss is usually latency; it is not automatically an incident. A mismatch between recipient-visible watermark evidence and distributed bytes deserves a human because the system has lost the claim it was designed to make.

PDF itself adds a hard boundary. ISO 32000-2 defines the document format, but an arbitrary uploaded PDF is still untrusted input to the parser and rasterizer. Validate size and type before enqueueing, isolate rendering with CPU, memory, and time limits, and never construct a shell command by concatenating an upload name. The original filename is metadata, not an executable argument and not a cache key.

Build an immutable publication record

Use a versioned artifact identity instead of /documents/42/thumbnail.png. A practical cache key can be the SHA-256 digest of a canonical manifest containing the watermarked PDF digest, page number, renderer revision, pixel geometry, color settings, and watermark policy revision. Canonical means the same logical manifest always produces the same byte sequence; RFC 8785 defines a JSON canonicalization scheme when JSON is the chosen representation.

The digest is not the signature. SHA-256 detects different bytes when compared with a trusted value, while a digital signature authenticates a statement using a private key. Keep those responsibilities separate. Sign a compact audit statement that names the artifact digests and publication context; do not sign an ambiguous concatenation of fields. This Go worker uses Ed25519 from the standard library and length-prefixes each field before signing, which removes boundary ambiguity. Node.js can enqueue the manifest and store the returned record, while Express remains responsible for authorization and HTTP delivery.

package evidence

import (
    "crypto/ed25519"
    "crypto/sha256"
    "encoding/binary"
    "encoding/hex"
    "errors"
)

type Publication struct {
    ShareVersion string
    PDFDigest    string
    ThumbDigest  string
    PolicyRev    string
    KeyID        string
    Signature    []byte
}

func Digest(data []byte) string {
    sum := sha256.Sum256(data)
    return hex.EncodeToString(sum[:])
}

func signingBytes(p Publication) ([]byte, error) {
    fields := []string{p.ShareVersion, p.PDFDigest, p.ThumbDigest, p.PolicyRev, p.KeyID}
    out := make([]byte, 0, 256)
    for _, field := range fields {
        if len(field) > 1<<20 {
            return nil, errors.New("audit field too large")
        }
        var size [4]byte
        binary.BigEndian.PutUint32(size[:], uint32(len(field)))
        out = append(out, size[:]...)
        out = append(out, field...)
    }
    return out, nil
}

func Sign(p Publication, privateKey ed25519.PrivateKey) (Publication, error) {
    message, err := signingBytes(p)
    if err != nil {
        return Publication{}, err
    }
    p.Signature = ed25519.Sign(privateKey, message)
    return p, nil
}

func Verify(p Publication, publicKey ed25519.PublicKey) bool {
    message, err := signingBytes(p)
    return err == nil && ed25519.Verify(publicKey, message, p.Signature)
}
Enter fullscreen mode Exit fullscreen mode

The worker still needs a PDF renderer. Keep that adapter replaceable and pin its revision in the manifest. Poppler, MuPDF, and PDFium expose different integration and licensing boundaries; they are examples, not endorsements. A process adapter offers a strong operational boundary but pays startup overhead. An in-process binding avoids that startup cost but puts parser crashes and memory pressure in the worker. Measure both with actual documents, especially large vector pages and PDFs with embedded fonts, because an average office memo will not expose the tail.

Render page index zero only after watermark output is finalized. Fix the image dimensions and encoding parameters. Strip unnecessary thumbnail metadata, then compute the digest from the exact encoded bytes that object storage receives. Upload under a digest-addressed name, verify the stored length and digest, write the signed publication record, and only then atomically mark the share version published.

Cache the artifact, not the authorization decision

The cache should follow mutability. A digest-addressed thumbnail is immutable, so Express may return Cache-Control: public, max-age=31536000, immutable only when the image is genuinely safe for shared caches. A recipient-specific or confidential preview should be private or delivered through an authenticated route whose CDN configuration cannot mix principals. RFC 9111 defines HTTP caching semantics; the header is a contract with caches, not a performance wish.

Authorization must run against the current share policy before disclosure. Do not assume an unguessable digest grants access. Revoked bytes may remain in a browser cache, which is why permission for public caching belongs in data classification, not an Express middleware optimization. For sensitive developer documents, a short private cache lifetime is an honest trade-off: more origin traffic for a smaller stale-access window.

Avoid overwriting an object at an existing key. Publish a new digest key and update the database pointer transactionally. Retries then become idempotent, and rollback is boring: the prior record still points to its original PDF and thumbnail, so reverting the pointer does not depend on cache invalidation racing across regions. Garbage collection may remove unreachable artifacts after the applicable retention interval.

The hot request path should not rasterize. Express reads the authorized publication record, selects its immutable thumbnail key, and serves or redirects to that object. If the record is pending, return an explicit pending state instead of silently serving an old thumbnail. If it failed, expose a stable failure category to operators without leaking parser details to the external recipient.

Verification before rollout

Start with fixtures that exercise meaning: a one-page PDF, a multi-page PDF whose second page looks different, a rotated first page, transparency, missing embedded fonts, an encrypted file the policy rejects, a truncated file, and a decompression-heavy input. Assert dimensions, media type, artifact digests, signature validity, and the link from share version to policy revision. Pixel snapshots help, but allow tolerance only when a renderer upgrade is expected to change antialiasing; never wave through a changed watermark region.

Then test corruption. Flip one byte in the stored PDF, thumbnail, audit message, and signature in separate cases. Each must prevent publication or fail verification. Replay a valid record under another share version and confirm the signed context rejects it. Run two identical jobs concurrently and confirm they converge on the same immutable objects without publishing contradictory records.

These are small tests with high signal.

For deployment, shadow-render a sample of already authorized documents with the new worker revision, compare dimensions and perceptual output, and keep the results out of the serving path. Promote only after resource limits and failure categories are visible. Track queue age, render duration by coarse page-complexity bands, rejected-input counts, worker terminations, publication-invariant failures, and signature-verification failures. Do not put document names or recipient identities into metric labels.

A useful service-level indicator is the fraction of authorized preview requests that return the thumbnail tied to the currently published share version within the latency objective. Raw cache-hit ratio cannot tell you that. Neither can a renderer-success graph, because a successful render attached to the wrong version is still wrong.

Ask what page fired.

Rollback without breaking the chain

Rollback the active renderer or watermark policy by creating a new publication version; do not mutate signed history. During a bad rollout, stop consumers, leave queued jobs intact, restore the last known worker revision, and resume with idempotency keyed by the canonical manifest digest. Records produced by the suspect revision remain verifiable evidence of what occurred, even when they are no longer active.

Key rollback differs from code rollback. Store a key identifier in every signed statement, retain public keys for the full audit-retention period, and keep private signing keys in a dedicated key-management boundary with narrowly scoped signing access. Rotation means new records use a new key ID. Revocation policy must distinguish a compromised key from a retired one, because deleting an old public key makes historical verification impossible. NIST FIPS 186-5 specifies EdDSA, including Ed25519, while RFC 8032 supplies the algorithm description and test vectors.

Finally, rehearse restoration from the database and object store into an empty environment. Verify every reachable artifact, rebuild only disposable indexes, and sample rendered pixels against signed records. Publish evidence as one immutable unit, or do not publish it. That gives a Node.js preview service a defensible answer at 3 a.m., when a polished dashboard matters less than proving which bytes went to which share version.

References

Top comments (0)