Each security requirement of Registry Render, named as a threat, with its enforcement point and the executable negative test that proves it. Rows 24-32 cover optional document sealing. Residuals that are accepted, with their conditions, follow the table.
| # | Threat | Enforcement point | Negative test |
|---|---|---|---|
| 1 | Untrusted data reaches a path-taking template function and reads files outside the bundle | RenderWorld::snapshot_path (world.rs): lexical rejection of ../absolute components followed by exact lookup in the immutable bundle snapshot; Typst additionally virtualizes absolute paths as root-relative before the world sees them |
golden::data_paths_cannot_escape_the_bundle; world::tests::virtual_paths_reject_escape_attempts |
| 2 | A template imports an unvendored package; render fetches from the network | There is no network code path in library mode; package resolution serves only packages/ through the world |
golden::unvendored_package_import_fails_without_network |
| 3 | Package content drifts after shared verification through tamper, accidental edit, or deterministic path replacement before Typst access | Shared verification checks SHA256SUMS; Render captures every file once and binds those exact bytes and the captured sum file back to the verified file digests before parsing the manifest, labels, schemas, fonts, templates, vendored package sources, or other project files. Serve repeats this in every worker and requires the worker's package digest to equal the startup digest. |
bundle::tests::package_load_refuses_bytes_replaced_after_shared_verification; golden::tampered_package_is_refused; golden::verified_template_and_typst_package_bytes_are_bound_to_the_loaded_snapshot; serve::package_changed_missing_and_extra_files_refuse_startup_by_name; serve::bundle_drift_after_serve_starts_is_refused_per_render |
| 4 | A directory without the shared envelope is served, a digest pin is bypassed, or invalid manifest bytes cause unrelated content to be trusted | runtime::load_package verifies the shared package and optional package.expectedDigest before semantic assembly; BundleSnapshot::load reads and parses the root manifest first through a secured root descriptor; workers repeat package verification for every render |
bundle::tests::source_load_checks_the_manifest_before_capturing_descendants; golden::directory_without_sum_file_is_not_a_package_but_remains_authoring_source; serve::serve_startup_package_digest_mismatch_uses_common_expected_and_found_shape; serve::bundle_drift_after_serve_starts_is_refused_per_render |
| 5 | Caller without the API key reads documents or renders | Bearer authentication as a layer on /v1/* before body buffering; constant-time compare; ≥32-byte ASCII key; identical 401 bodies for missing/wrong/short keys; the key file gets exactly one trailing line ending trimmed and any other whitespace refuses startup (no silently mis-armed key with /health green) |
serve::unauthorized_requests_are_refused_and_audited (two indistinguishable 401s); serve::api_key_file_with_one_trailing_newline_is_trimmed; serve::api_key_with_stray_whitespace_is_refused_at_startup; /v1/documents behind the same layer |
| 6 | The append-only audit log is used as an unauthenticated write oracle (huge or hostile route params) | Route parameters are bounded (64 chars) and kebab-validated (sanitize_document_type) before any audit append, including pre-auth 401 events |
code-reviewed; audit shape asserted in serve::unauthorized_requests_are_refused_and_audited |
| 7 | Request data values or the API key leak into the audit log or logs | Audit events carry a closed, value-free field set; lifecycle logs use fixed dimensions (method class, route template, status, latency, trace id); the seal route's seal refused warning carries only the seal id and the problem and reason names |
serve::audit_events_are_value_free (canary + key scans over the audit file) |
| 8 | A render starts, or a document leaves, with no accepted audit record of it | The shared audit writer accepts a file entry only after fsync (owner-only file, single-writer lock) and a stdout entry only after it is written and flushed, then stops accepting after any failure. The request entry is accepted before the worker starts, else 503 audit-failed and no render; the response entry is accepted before the document leaves, else 503 audit-failed and no PDF or hash headers; /ready reports a stopped writer. Each call pairs its entries under a correlation the server draws, never the caller's Idempotency-Key (echoed only as correlationId), so calls sharing a key cannot be confused, and a call that ends before its outcome is written writes an unfinished response. Host-level tampering with the log is not defended here: the log carries no hash chain or signature (residual below) |
server::tests::the_request_entry_is_accepted_before_the_render_starts; server::tests::a_refused_request_entry_prevents_the_render; serve::a_refused_request_entry_prevents_the_render; serve::a_refused_response_entry_withholds_the_pdf; serve::a_render_writes_a_request_entry_then_a_response_entry_sharing_correlation (file mode 0600); server::tests::concurrent_calls_sharing_an_idempotency_key_pair_their_own_entries |
| 9 | A render succeeds but is wrong on paper (missing glyphs) | Check-time label-script coverage over the actual label characters; package validates before it writes any output; render-time warnings; --strict fails on warnings |
scaffold::package_refuses_a_broken_bundle_before_writing_output (CJK label, no font, so the package directory is never created) |
| 10 | Pathological template data exhausts CPU or memory | Serves render in a supervised worker process: killed at the configured timeout, which also bounds its exit after answering, RLIMIT_AS on Linux, recycled on panic; bounded concurrency, and as many seals in flight as render slots once a seal releases its slot, a seal counted until its preparation ends even after its client has gone; request body and output caps with hard ceilings |
serve::pathological_renders_are_bounded_and_the_service_recovers (timeout or memory wall is audited; a fresh worker immediately serves a healthy render); worker::tests::a_worker_that_lingers_after_answering_is_cut_off_at_the_deadline; serve_seal::seals_in_flight_beyond_the_render_concurrency_are_refused_as_rate_limited; seal::tests::a_dropped_seal_keeps_its_place_until_its_blocking_work_ends |
| 11 | Assets smuggle executables or balloons | base64-decoded, JPEG/PNG magic sniffed, per-asset 2 MiB / per-request 8 MiB caps, exact bytes covered by dataDigest |
golden::wrong_media_type_and_oversize_assets_are_refused |
| 12 | Deeply nested JSON parse DoS | serde_json default 128-depth recursion limit on top of the tower body-limit layer |
platform-owned; body limit exercised by config ceiling (runtime.rs 64 MiB hard cap) |
| 13 | Response header injection via echoed Idempotency-Key |
HeaderValue::from_str rejects CR/LF/NUL; 128-char bound at read |
code-reviewed; echo asserted in serve::render_returns_pdf_with_hash_headers_matching_golden |
| 14 | Byte drift between renders, machines, or dependency upgrades | Fresh world+library per render, canonical (RFC 8785) envelope = injected = hashed bytes, deterministic font order, ident: Auto (content-derived), comemo eviction; lockfile pins Typst and the deflate stack |
golden::golden_hashes_match + rendering_is_deterministic… on two OSes in CI (.github/workflows/render-golden.yml); merge-gate record in EVIDENCE.md |
| 15 | Package checksum coverage misses nested files, extra files, or symlinks | The shared writer lists every product file and optional REVISION in sorted SHA256SUMS; verification refuses changed, missing, extra, symbolic-link, and special-file entries. Render opens every root spelling component and descendant through held directory descriptors with NOFOLLOW and accepts only opened regular files before binding the captured set to that shared verification. |
scaffold::bundle_with_symlink_cannot_be_packaged; serve::package_changed_missing_and_extra_files_refuse_startup_by_name; serve::bundle_drift_after_serve_starts_is_refused_per_render; bundle::tests::bundle_root_and_ancestor_symlinks_are_refused_for_every_spelling |
| 16 | 401 and 413 audit-append failures vanish silently | Failures are logged at error level even though the refusal itself stands | code-reviewed with the lifecycle-log middleware |
| 17 | Compile diagnostics or bundle-load failures leak the host deployment (paths) or raw engine output | World file errors carry virtual, root-relative paths only; a redaction pass replaces any residual host bundle root with <bundle> in problem details and warnings, and per-request bundle-load failures in the worker are redacted the same way before they cross the pipe |
golden::compile_diagnostics_report_virtual_paths_only; world::tests::not_found_reports_the_virtual_path_not_the_host_root; serve::bundle_drift_after_serve_starts_is_refused_per_render (a refused symlink names <bundle>/escape, never the host path) |
| 18 | Oversized or unframed bodies DoS the service or dodge the audit trail | Body ceiling enforced after authentication (unauthenticated oversized bodies are 401s, never buffered); an authenticated over-ceiling body gets the audited body-too-large problem (413), and chunked transfer is refused up front with an audited problem — once a chunked stream trips the stream limit mid-body the connection is broken and no response can be delivered at all, so refusal must precede the first body byte; the tower stream limit stays as the last-ditch backstop (a mid-stream abort there closes the connection: the residual recorded below) |
serve::oversized_bodies_are_refused_after_auth_as_problems; serve::chunked_bodies_are_refused_upfront_as_problems |
| 19 | A panicking worker's stderr (paths, data fragments) reaches the operator log | Worker stderr is piped, drained and discarded. For an observed exit before a complete response frame, the CLI diagnostic and server warning use the OS exit status and the configured address-space cap on Linux. HTTP callers receive fixed problem detail | worker::tests::a_worker_that_exits_before_answering_reports_only_bounded_process_details (stderr canary, exit code and signal); serve::pathological_renders_are_bounded_and_the_service_recovers (fixed HTTP detail) |
| 20 | Serve listens on a public or all-interfaces address | runtime::load and the offline runtime check refuse a non-loopback/private/unspecified listener.bind with render.runtime.public-bind at its position, before startup touches the filesystem, and runtime::validate_bind refuses it again in serve; listener.bind is required, so no address is chosen implicitly |
runtime::tests::loopback_private_and_link_local_binds_are_allowed, runtime::tests::public_and_unspecified_binds_are_refused, runtime::tests::a_public_bind_in_the_file_is_refused_at_its_value, runtime::tests::the_listener_bind_is_required, serve::a_refused_bind_is_caught_before_startup_touches_the_filesystem |
| 21 | A caller-controlled value makes a refusal unbounded (response and log amplification) | problem_response writes a fixed detail chosen by the problem kind, so no caller value (an undeclared locale, an asset name, the path) reaches it; validation pointers are cut to 32 of at most 256 characters each; the request log records the route template, method class, status, latency and a trace id only when the traceparent parses, at 32 characters |
serve::problem_details_are_bounded (a 50,000-character locale); server::tests::maximum_pointer_problem_fits_the_serving_client_budget; server::tests::fallback_has_the_same_complete_safe_problem_envelope; server::tests::the_request_log_takes_a_route_template_and_a_parsed_trace_id |
| 22 | A shutdown grace that disarms the drain or overflows the bounded wait | runtime::load refuses listener.shutdownGraceMilliseconds outside 1000 to 3600000 at startup (config.out-of-range), so less than a second cannot drop renders in flight and a huge value cannot panic the wait that adds the grace twice |
runtime::tests::cfg_qty_4_shutdown_grace_outside_its_bounds_is_refused; runtime::tests::shutdown_grace_at_the_range_ends_is_accepted |
| 23 | A credential in the serving process (a secret provider variable, a ${VAR} value, anything the host injects, or a secret it has loaded) reaches the worker process that runs template code |
worker::worker_command, the one constructor every supervised worker (serve and compile) is spawned from, clears the environment and keeps only the dynamic loader's search paths (DYLD_FALLBACK_LIBRARY_PATH, DYLD_LIBRARY_PATH, LD_LIBRARY_PATH), which the same executable needs to load its shared libraries; on Linux, server::serve marks the process non-dumpable before it reads any secret, so a same-user process cannot read its /proc/<pid>/environ or memory |
worker::tests::a_worker_sees_no_parent_variable_beyond_the_loader_paths (spawns the production command under the test process's real environment); worker::tests::a_worker_keeps_the_parents_loader_paths; worker::tests::only_the_loader_search_paths_are_kept; server::tests::serving_marks_the_process_not_dumpable (Linux only); serve::another_process_of_the_serving_user_cannot_read_the_serving_environment (Linux only: the real serve child, with a control process the same user can read) |
| 24 | A caller or client not meant to seal gets a document sealed in the institution's name | POST /v1/seal/{type} sits behind its own bearer layer (server::require_seal_bearer) that demands render:seal, which render:render does not imply; server::seal_route then refuses a verified client not in the seal's allowedClients (seal-client-not-allowed, 403) before any render; runtime::signing_findings refuses at startup a seal with no client list, a wildcard or unrestricted client, or a client authentication.oidc.allowedClients does not admit. Each refusal is one audited response entry |
serve_seal::each_authorization_refusal_has_its_own_problem; auth::tests::each_route_scope_authorizes_only_its_own_route; runtime::tests::the_unrestricted_refusal_states_why_and_never_repeats_a_client |
| 25 | The legacy API key seals | runtime::signing_findings refuses a signing block without authentication.oidc (render.runtime.signing-requires-oidc), and server::require_bearer refuses a seal request on a service with no OIDC verifier (forbidden, 403) even if that check were bypassed |
serve_seal::the_legacy_api_key_never_seals (startup refusal). The request-time refusal in require_bearer is a second line of defence with no dedicated test (code-reviewed) |
| 26 | The seal's private key reaches the worker that runs template code | A production seal's key never enters Render: runtime::signer_findings requires a transit signer under assuranceProfile: production, and TransitSigner sends Transit only the digest to sign. A test seal's key file is resolved and held only in the serving process (seal::Seal::load, LocalJwkSigner). The worker's request (worker::WorkerSeal) carries the signing member alone, never a key; worker::worker_command clears the worker's environment (row 23). A key file stays readable to a worker that gained code execution (residual "Local seal key files") |
seal::tests::the_seal_key_never_crosses_into_the_worker; worker::seal_tests::a_seal_request_returns_the_sealed_first_revision_only; worker::tests::a_worker_sees_no_parent_variable_beyond_the_loader_paths |
| 27 | A worker returns a file that makes the parent sign something other than the rendered document | The worker answers with the first revision only and the parent writes every byte after it. server::first_revision holds the answer to limits.maximumOutputBytes; seal::Seal::seal hashes all of it as documentDigest, then runs pdf_signature::prepare on it in a blocking task. prepare refuses anything but a single-revision PDF with a cross-reference table (an update or bytes after the end marker included) before hayro-syntax reads it. Its bounded reader walks every object to a nesting depth of 32 and the page tree to a depth of 32, reaching each node once, and accepts only files that hayro-syntax 0.7.2 reads as written, without rebuilding its cross-reference table by scanning the whole file, as established by a reading of that version's source for the table, trailer and objects, with one regression test for each difference found, not by exhaustive or fuzzed proof; the workspace pins hayro-syntax to exactly that version, so a new version needs that reading again. It also refuses a stream hayro-syntax would not read (one naming an external file, without an end-of-line after stream, or without endstream), so the signature check sees every object the cross-reference table lists. A sealed file is therefore the worker's answer followed by the parent's own update, and anything a worker forges sits under documentDigest (residual "A compromised worker"). The parent types the worker's frame as it reads it (worker::WorkerResponse: externally tagged, unknown members refused), so a frame costs no more than its 64 MiB. server::from_served_package compares the package digest the worker reports with the startup digest on both routes (bundle-tampered): drift detection for an honest worker, since the digest is the worker's own report |
seal::tests::a_worker_cannot_put_signed_bytes_outside_the_document_digest; worker::seal_tests::a_seal_request_returns_the_sealed_first_revision_only; worker::tests::a_worker_answer_is_typed_as_the_frame_is_read; server::tests::a_first_revision_is_held_to_the_output_limit; server::tests::an_answer_from_another_package_is_refused_as_tampered; pdf_signature::prepare_refuses_its_own_output_and_a_signed_file; pdf_signature::prepare_refuses_garbage_without_panicking; pdf_signature::tests::prepare_refuses_deep_nesting_before_an_unbounded_reader_sees_it; pdf_signature::tests::prepare_refuses_a_page_tree_an_unbounded_reader_cannot_finish; pdf_signature::tests::prepare_refuses_a_file_hayro_syntax_would_rebuild_by_scanning; pdf_signature::tests::prepare_refuses_a_stream_hayro_syntax_cannot_read; pdf_signature::syntax::tests::startxref_and_size_fit_what_hayro_syntax_reads; pdf_signature::embed_takes_exactly_the_capacity_and_refuses_more_or_nothing; seal::tests::the_reserved_space_follows_the_documented_formula; serve_seal::a_chain_too_large_for_the_reserved_space_refuses_startup |
| 28 | The seal audit leaks a subject, signer name, key or token, or a sealed call goes unaudited | The audit record gains only value-free fields: sealId, testOnly (only true, for a test-only seal), documentDigest, signedFileDigest, certificateDigest and timestampTokenDigest (audit::RenderAuditEvent::for_seal), and on a timestamp-failed refusal a reason from the closed list seal::response_failure and TimestampAuthority::stamp name. The request entry is accepted before the worker starts and the response entry before the file leaves (row 8); the certificate is named by its SHA-256 digest only |
serve_seal::a_seal_writes_a_paired_value_free_audit (canary scans for the organization, subject, client, principal, issuer and token); audit::tests::a_seal_request_entry_names_its_seal_and_no_response_value; audit::tests::an_unfinished_seal_keeps_its_seal_and_drops_every_response_value; audit::tests::a_seal_refusal_names_its_seal; serve_seal::a_test_only_seal_says_so_in_every_answer_and_a_production_seal_adds_nothing |
| 29 | A timestamp failure silently produces a seal without a timestamp | A seal with timestampAuthority is B-T or nothing: seal::Seal::seal maps an unreachable, refusing, malformed or oversized timestamp response, and a token too large for the reserved space, to timestamp-failed (502) with no retry and no file. B-B exists only as the named withoutTimestamp choice, and runtime::signing_findings refuses a seal declaring neither or both |
serve_seal::a_failing_timestamp_authority_fails_the_seal_naming_the_piece (seven failure modes, one authority request each, no pdfBase64, refused audit entry and one log warning naming the reason); serve_seal::a_b_b_seal_is_the_named_choice_and_carries_no_timestamp |
| 30 | A seal key is printed, left readable to other users, overwritten, read from the environment, or a refused key form is decoded | keygen::run creates the key file new with mode 0600 and never overwrites one, writes the certificate request beside it or removes the key, and prints paths only; platform_crypto::private_seal_key_json writes the private JWK into one zeroizing buffer and refuses rather than reallocate. runtime::signing_findings refuses an environment reference for signer.privateKeyRef (render.runtime.seal-key-requires-file-provider), so the key is read through the file provider, which takes only an owner-only (0400 or 0600), single-link file it opens without following a symlink. platform_crypto::seal_key_from_pkcs8_pem refuses encrypted PKCS #8, SEC 1, PKCS #1 and PKCS #12 by their markers before decoding any key material in them. platform_crypto::certificate_request verifies the request it signed under the key's public half before returning it |
keygen::keygen_writes_an_owner_only_key_and_a_request_openssl_verifies (mode 0600, no key member printed); keygen::keygen_never_overwrites_and_leaves_no_half_pair; keygen::keygen_refuses_a_bad_subject_before_writing_anything; runtime::tests::a_seal_key_must_name_the_file_provider_even_with_environment_declared; document_signature::seal_key_tests::pkcs8_import_refuses_other_key_forms_without_reading_them; document_signature::seal_key_tests::pkcs8_import_refuses_a_pkcs12_file; document_signature::seal_key_tests::a_certificate_request_refuses_a_provider_that_does_not_sign_it |
| 31 | A production seal signs with a key file, with a Transit key its certificate does not certify, or with a Transit key that can leave Transit | runtime::signer_findings refuses assuranceProfile: production with a local-jwk signer, and assuranceProfile: local with a transit signer (render.runtime.signer-assurance-mismatch), as Evidence does, and the runtime schema's if/then says the same. TransitSignerConfig::discover pins the key version's public key only after refusing a key that is exportable, derived, allows plaintext backup, is not ecdsa-p256, or a version outside min_encryption_version..latest_version (render.runtime.seal-transit-unavailable); CertificateChain::check_for_signing then refuses a key the seal certificate does not certify (render.runtime.seal-key-mismatch) before the seal signs anything, and TransitSigner::initialize signs once and verifies. keygen in Transit mode writes the request alone, signed by Transit. An imported (bring-your-own-key) Transit key passes this custody check, which refuses only a key that can leave Transit, so where the key material was before its import is the institution's to account for. Custody is read at startup only: a change to the key's flags after serve starts shows at the next start |
runtime::tests::a_production_runtime_never_signs_with_a_key_file; runtime::tests::a_local_runtime_never_signs_through_transit; runtime::tests::seals_need_an_assurance_profile; serve_seal::a_production_runtime_never_signs_with_a_key_file; schema::tests::signing_members_are_judged_alike_by_schema_and_reader; serve_seal::a_transit_key_the_certificate_does_not_certify_refuses_startup_by_position; serve_seal::a_transit_proxy_that_is_not_running_refuses_startup_by_position; serve_seal::a_production_seal_signs_through_its_transit_key (no key file exists); tests::transit_discovery_refuses_what_initialization_refuses (platform-crypto); tests::transit_signer_accepts_openbao_native_metadata_and_sign_response_fixtures (platform-crypto; its key is marked imported); document_signature::tests::a_transit_held_seal_key_signs_under_its_certificate; keygen::keygen_requests_a_certificate_for_a_transit_key_and_writes_no_key_file; keygen::keygen_refuses_a_transit_key_it_cannot_use_before_writing_anything |
| 32 | A Transit outage leaves a seal unavailable after Transit recovers, recovery floods Transit with sign calls, or a stalled Transit holds /ready |
Seal::start_signer_probe probes only a signer that reports not ready, with at most one probe in flight per seal: the probe runs as its own task and holds the probe lock (try_lock_owned) until it ends, so a caller that disconnects cannot release it, and a concurrent caller does not wait. After a failed probe, the next starts no sooner than one second after it ended, a time kept under the same lock; a probe that recovers the signer clears it, so the first probe after the signer turns not ready again is not delayed. The probe signs a fixed message, never caller bytes, and its signature is discarded. /ready starts a probe and never waits on Transit: it reports the state the last finished probe left. The seal route waits for the probe it starts (Seal::recover_signer) before it reads availability, so a seal recovers without a restart, through /ready or through seal requests alone. A probe still running at shutdown neither holds the drain nor panics |
seal::tests::a_signer_that_turned_not_ready_is_probed_once_and_recovers; seal::tests::a_failed_probe_holds_off_the_next_from_when_it_ended; seal::tests::a_caller_that_goes_away_leaves_its_probe_running_and_holding_the_lock; serve_seal::a_transit_seal_is_unavailable_while_its_proxy_is_down_and_recovers_without_a_restart (recovery through /ready, then through seal requests alone); serve_seal::ready_answers_at_once_while_a_probe_waits_on_a_stalled_transit_proxy (/ready under one second against a proxy that never answers, one connection at the proxy, a clean exit on SIGTERM); serve_seal::a_burst_of_ready_calls_sends_transit_at_most_one_probe_a_second (sign requests counted at the proxy, failures logged without values) |
- A render worker is not a sandbox. It runs as the serving process's
user, with the same filesystem and network. A worker that gained code
execution through a Typst or decoder defect could read files that user can
read, including key files under
secretProviders.file.root, connect to any socket that user can reach, and signal the serving process, which can stop the service. On Linux, row 23 keeps the parent's environment and memory out of its reach. On macOS it does not: a same-user process reads another's arguments and environment throughKERN_PROCARGS2, so such a worker can read every${VAR}andsecret:envvalue the serving process holds; seal on Linux. The rest is not enforced. The worker needs only its pipes, so refusing socket creation and restricting its filesystem view (seccomp, Landlock) are the next steps, not yet taken because they need per-architecture filters proven on the release image. - The audit log is not tamper-evident on the host. It carries no hash
chain or signature, so a host user able to write the audit file can
remove, rewrite, or reorder lines undetected. A render worker that gained
code execution is such a user: it runs as the serving user, who owns the
filedestination. Deployments that need tamper evidence ship thefileorstdoutstream to append-only storage they control; deployments that seal useaudit.destination: stdout, shipped to storage the serving user cannot write. - macOS workers have no address-space cap (
RLIMIT_ASis not exposed by rustix there): the timeout kill is the sole wall. Serve deployments for pilots should run on Linux, where the rlimit applies. - The output-size check runs after PDF export in memory; Linux workers
bound the spike by
RLIMIT_AS, macOS only by the kill. - A chunked body that trips the tower stream limit mid-body breaks the request stream, so the connection closes with no response and no audit event. Refusing chunked transfer before the first body byte is what keeps that path unreachable for ordinary callers; the backstop remains for a client that frames a Content-Length and then sends more.
- Asset caps are code-level constants (per-asset 2 MiB, request total 8 MiB, JPEG/PNG only), not per-document manifest declarations. Per-document asset policy is a v2 option if a journey needs it.
- A compromised worker can still get arbitrary content sealed. The worker
renders the content, so one that gained code execution can return any
single-revision document, and the parent signs it. That content sits in
the first revision, under
documentDigest, because the parent writes every byte after it (row 27): comparing the digest with the record, or re-rendering from the record and comparing, exposes it. The parent bounds what it signs (rows 26 and 27), not what the document says. The mitigations are governed templates, which are verified at startup and by each worker (rows 3 and 4), the key staying outside the worker (row 26), and the value-free audit (row 28) that lets an institution reconcile every sealed hash with its own records. The same worker can rewrite afileaudit destination (previous residual), so that reconciliation holds only for an audit sent tostdoutand on to storage the serving user cannot write. Such a worker can also connect to a Transit seal's proxy socket, which the serving user can reach, and have Transit sign digests of its choosing outside the parent and its audit. A Vault or OpenBao audit device, when one is enabled, records each such call; the worker sandbox the first residual names, which refuses socket creation, is what would close it. - The timestamp authority's identity is not pinned. Render reaches the
authority over TLS with the host's root certificates, or with only the CA
bundle
timestampAuthority.caBundleRefnames, and accepts a token that matches the signature, nonce and key usage; it does not pin the authority's certificate or decide whether it is trusted. Validators decide trust in the seal certificate and the timestamp authority. - Local seal key files are for test seals.
A
local-jwksigner'sprivateKeyRefresolves a JWK through the file secret provider, so the key is readable by the serving user and, with the worker not a sandbox (first residual), by a worker that gained code execution.assuranceProfile: productionrefuses a key file (row 31): production custody is Transit, reached through a signing proxy. A compromised serving host can still ask that proxy to sign for as long as it is compromised, but cannot take the key. A PKCS #11 HSM or a cloud key service is not supported. Reading a key file leaves two copies of key text that Render does not erase: a file the strict JSON parser refuses (bad syntax, a duplicate member) drops its partial parse tree insideregistry-platform-canonical-json, andserde_jsonkeeps a scratch copy of a string written with escapes, which a base64url member does not need. /readyis unauthenticated. Anyone who reaches the listener reads each seal's id, readiness, value-free reason and test-only mark, and, while a Transit seal is not ready, makes it probe Transit over a fixed message: at most one probe in flight per seal, and the next no sooner than one second after a failed one ended, however many calls arrive./readynever waits on Transit, so a stalled proxy cannot hold the readiness answer an orchestrator acts on (row 32;serve_seal::ready_answers_at_once_while_a_probe_waits_on_a_stalled_transit_proxy,serve_seal::a_burst_of_ready_calls_sends_transit_at_most_one_probe_a_second). The listener stays on a loopback or private address (row 20).- No revocation data, no qualification. A seal is PAdES baseline B-B or B-T. It embeds no revocation information (no B-LT or B-LTA) and makes no eIDAS qualification claim.
- PDF/A conformance rests on the template. veraPDF passed a fixture
template at every accepted PDF/A level and at PDF/UA-1, rendered and
sealed (
EVIDENCE.md), so the seal keeps a level the rendered file meets;checkrefuses a reserved space the level's string limit cannot hold. A deployer's own template is not checked against its level, and veraPDF checks machine-checkable rules only.