Skip to content

Latest commit

 

History

History
135 lines (131 loc) · 32 KB

SECURITY-MATRIX.md

File metadata and controls

135 lines (131 loc) · 32 KB

Security-invariant matrix

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)

Documented residuals (accepted, with conditions)

  • 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 through KERN_PROCARGS2, so such a worker can read every ${VAR} and secret:env value 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 file destination. Deployments that need tamper evidence ship the file or stdout stream to append-only storage they control; deployments that seal use audit.destination: stdout, shipped to storage the serving user cannot write.
  • macOS workers have no address-space cap (RLIMIT_AS is 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 a file audit destination (previous residual), so that reconciliation holds only for an audit sent to stdout and 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.caBundleRef names, 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-jwk signer's privateKeyRef resolves 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: production refuses 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 inside registry-platform-canonical-json, and serde_json keeps a scratch copy of a string written with escapes, which a base64url member does not need.
  • /ready is 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. /ready never 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; check refuses 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.