diff --git a/conformance/README.md b/conformance/README.md index cc8a278..2d923b2 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -9,6 +9,11 @@ a maintainer from the published-artifact command below. Every result is recorded as a concise update on the active release issue so the tuple, outcome, and next action remain visible without creating a second backlog. +The [payload protection v1 contract](payload-protection/README.md) fixes the +portable protected envelope, authenticated context, deterministic vector and +initial supported/refused call-site inventory. It is an implementation contract, +not a runtime qualification or key-provider grant. + ## Native Server qualification Native Server is the continuing Server line. Use its diff --git a/conformance/payload-protection/README.md b/conformance/payload-protection/README.md new file mode 100644 index 0000000..606aa38 --- /dev/null +++ b/conformance/payload-protection/README.md @@ -0,0 +1,165 @@ +# Payload protection v1 contract + +This directory is the implementation contract for opt-in application-payload +protection across Durable Workflow SDKs, Workflow and Server. It fixes the +wire envelope, authenticated context, deterministic vector and initial call +site scope before any implementation is accepted. + +The contract does not encrypt protocol metadata, grant a key-provider or +runtime effect, or make a partially protected deployment safe. Implementations +must use an established XChaCha20-Poly1305 library. Python implementations use +PyNaCl/libsodium; the 12-byte-nonce ChaCha20-Poly1305 construction is not a +substitute. + +## Envelope recognition + +Protected bytes are Avro single-object encoded with +[`ProtectedValue`](protected-value-v1.avsc), whose fingerprint is +`0ba70c3fd6c311ca`. The existing plain `Value` fingerprint remains +`e2a33dff55802237`. A decoder selects the schema from the single-object +fingerprint before decoding the record. A user map containing +`dw_protected`, `alg`, `key_id`, `nonce` or `ciphertext` is still an ordinary +plain value and is never reinterpreted as an envelope. + +The protected record is valid only when: + +- `dw_protected` is exactly `1`; +- `alg` is exactly `xchacha20poly1305-ietf`; +- `key_id` matches `[A-Za-z0-9._:-]{1,128}`; +- `nonce` is exactly 24 bytes; and +- `ciphertext` contains at least the 16-byte authentication tag. + +These structural checks happen before key lookup. Writers use a fresh +cryptographically random nonce for every new protection operation; they do not +derive a nonce from payload or request identity and do not deliberately reuse a +key/nonce pair. + +## Authenticated context + +AAD is the exact byte concatenation: + +```text +"durable-workflow.payload-protection\0" +|| u8(1) +|| lp("xchacha20poly1305-ietf") +|| lp(key_id) +|| lp(namespace) +|| lp(boundary_kind) +``` + +`lp(x)` is `u32be(len(UTF8(x))) || UTF8(x)`. There is no Unicode, case, path or +namespace normalization. The destination or owning namespace comes from the +authoritative protocol context, never an untrusted payload field. The allowed +boundary kinds are: + +```text +workflow_input workflow_result activity_input +activity_result signal_input update_input +update_result query_input query_result +side_effect_result +``` + +Scheduled starts, child starts and continue-as-new use `workflow_input`. +External storage inherits the logical boundary kind; storage location is not a +new kind. + +## Plaintext and errors + +AEAD plaintext is the complete existing Avro `Value` single-object byte +sequence, including its fingerprint. A successful AEAD open is not enough: the +inner bytes must be exactly one valid plain `Value` with no trailing bytes. + +Readers apply this stable refusal precedence: + +1. malformed protected envelope, unsupported version or algorithm: + `payload_envelope_unsupported`; +2. structurally valid envelope with unknown `key_id`: + `payload_key_unavailable`; +3. authentication failure, including namespace or boundary substitution: + `payload_authentication_failed`; +4. authenticated bytes that are not one complete plain `Value`: + `payload_plaintext_invalid`. + +An enabled boundary is strict by default: a plain `Value` is +`payload_protection_required`. An explicitly configured migration reader may +accept historical plain values. It must never treat a recognized protected +envelope as plain or fall back after any protected-envelope failure. A reader +that has no protection support refuses a recognized protected fingerprint as +`payload_envelope_unsupported`. + +## Keys, retry and offload + +Keys are exactly 32 bytes. A `key_id` is immutable: one active writer key may +be selected, old reader keys remain available for historical payloads, and an +ID cannot be reassigned or shadowed. Decryption selects the named key only; it +does not try every key. + +New admission protects once. Automatic retries resend the exact protected +bytes, command/idempotency key and external-object reference. A retry that can +cross a process boundary therefore needs a persistable prepared request that +contains the operation, destination namespace, boundary kind, command key and +protected bytes or reference. It contains no credentials or key material. +Retry code must not regenerate a nonce or ciphertext. + +Protection precedes the inline-size/offload decision. The logical protected +value is the binary Avro single-object frame. The v1 HTTP and external-payload +representation is the canonical base64 text of that frame: an external object +contains exactly the ASCII bytes of the base64 string, with no whitespace or +trailing newline. It does **not** contain the decoded Avro frame. The external +reference size and SHA-256 cover those ASCII bytes. A reader verifies that +declared size/hash before strict base64 decoding and decryption. + +## Initial call-site scope + +[`call-sites-v1.json`](call-sites-v1.json) is the source-mapped inventory. +The supported rows are workflow input/result, ordinary durable activity +input/result, signal input, update input/result, query input/result and +side-effect results. Heartbeats, streams, Nexus, local/prepared activities, +unmapped external tasks, failure details and raw JSON/binary paths are +unsupported in v1. When protection is enabled, those paths refuse instead of +leaking plaintext. + +The inventory is pinned to exact SDK, embedded Workflow and native Server +revisions. Each row names the actual wire, database, history or cache fields +and distinguishes inline envelopes, bare Value strings, in-memory values and +the two existing external-reference formats. An Avro-shaped field is not +automatically in scope. Strict mode must remove query/update plaintext +projections and raw-result fallbacks, and it must refuse unsupported paths +before their existing codec or upload hooks run. + +| Call site | v1 disposition | AAD boundary | Required behavior | +| --- | --- | --- | --- | +| Workflow input, scheduled input, child input, continue-as-new | Protected | `workflow_input` | Protect at original admission; reuse exact bytes on retries/redrives. | +| Workflow result | Protected | `workflow_result` | Protect the completion value before persistence or offload. | +| Ordinary durable activity input | Protected | `activity_input` | Protect the scheduled arguments; local/prepared variants are not implied. | +| Ordinary durable activity result | Protected | `activity_result` | Protect the completion value before history persistence. | +| Signal input | Protected | `signal_input` | Bind to the target workflow namespace. | +| Update input | Protected | `update_input` | Protect once at update admission. | +| Update result | Protected | `update_result` | Preserve the same envelope for completion wait and later outcome reads. | +| Query input | Protected | `query_input` | Bind request and worker task to the target namespace. | +| Query result | Protected | `query_result` | Protect the worker result before returning or caching it. | +| Side-effect result | Protected | `side_effect_result` | Persist and replay the original envelope unchanged. | +| External object for a supported row | Protected | Inherited | Protect first; offload the complete envelope and verify size/hash before decrypt. | +| Activity heartbeat details | Refuse | — | Raw JSON has no v1 portable protected envelope. | +| Workflow message stream item | Refuse | — | Streaming framing and replay semantics are not specified. | +| Nexus request or result | Refuse | — | Caller/target namespace binding is not specified. | +| Local or prepared activity payload | Refuse | — | No protection until every persistence/retry path uses the portable envelope. | +| External task payload outside ordinary activity | Refuse | — | Do not infer the ordinary activity boundary without exact protocol proof. | +| Failure details | Refuse | — | Do not partially encrypt ad hoc detail fields; failure diagnostics remain metadata. | +| Raw JSON or generic binary payload | Refuse | — | Never wrap an untyped transport body as a protected `Value`. | + +Identifiers, names, queues, namespaces, search/memo/visibility data, failure +messages/stacks/types, deadlines, policies and statuses remain plaintext +protocol metadata. This is intentional and must not be described as full +record encryption. + +## Vector + +[`vectors-v1.json`](vectors-v1.json) contains the canonical Unicode and integer +boundary vector, exact AAD and inner bytes, deterministic ciphertext, complete +outer Avro object, the decoded-frame length, and the external ASCII-base64 +object size/hash. The fixed key and nonce are test data only and must never be +used outside conformance tests. + +The contract originates from the accepted decisions in +[durable-workflow/.github#217](https://github.com/durable-workflow/.github/issues/217). diff --git a/conformance/payload-protection/call-sites-v1.json b/conformance/payload-protection/call-sites-v1.json new file mode 100644 index 0000000..c8cfea6 --- /dev/null +++ b/conformance/payload-protection/call-sites-v1.json @@ -0,0 +1,224 @@ +{ + "contract": "durable-workflow.payload-protection/call-sites-v1", + "source_revisions": { + "sdk-python": "949d38bb59aa7221f1e05f1309f3a6f0f53c8d96", + "sdk-php": "61477048340e8ab374822381a9018df7db050872", + "sdk-rust": "1129d797f266e518eedf642460c89b412dca9dad", + "workflow": "8bb05a6368b4046602fcae2fdd3a075f36dbf3ae", + "server": "00f20996a18d4795fa0a09319470df1f5160c079" + }, + "representations": { + "inline_envelope": "JSON object {codec:\"avro\",blob:}", + "bare_inline_value": "canonical base64 text of one complete Avro single-object Value without a surrounding envelope", + "external_runtime_reference": "JSON object {codec:\"avro\",external_payload:{schema,codec,reference_id,size_bytes,sha256}} whose object body is the exact ASCII base64 blob text", + "external_embedded_reference": "database text dw-external-payload:v1:; history uses {codec,external_storage}", + "external_native_reference": "wire object {codec:\"avro\",external_payload:}; persisted payload text dw-native-external-payload-v1:", + "protected_external_object": "exact ASCII bytes of canonical base64 text for the complete ProtectedValue single-object frame, with no whitespace or trailing newline", + "plain_json": "ordinary JSON document value with no portable Value envelope" + }, + "global_requirements": [ + "Protected is a v1 requirement, not a claim that a pinned implementation already encrypts payloads.", + "Protection is boundary-aware; diagnostic kind strings, generic codec hooks and external-upload allowlists are not AAD or protection admission.", + "Strict enabled mode suppresses every parallel plaintext projection and refuses every raw fallback for a protected boundary.", + "Unsupported boundaries refuse before encoding or upload even when their current transport happens to use an Avro Value envelope.", + "Absent optional arguments retain omission semantics and do not create an empty protected value." + ], + "boundaries": [ + { + "name": "workflow input", "status": "protected", "boundary_kind": "workflow_input", "namespace_authority": "destination workflow namespace", + "field_mappings": [ + {"binding":"sdk-python","anchors":["src/durable_workflow/client.py:4052,1383,5403","src/durable_workflow/workflow.py:1268,1140","src/durable_workflow/worker.py:3921"],"fields":["POST /workflows input","schedule action.input","child/continue commands[].arguments","task.arguments"],"inline":"inline_envelope","external":"external_runtime_reference"}, + {"binding":"sdk-php","anchors":["src/Client.php:522","src/Model/ScheduleAction.php:23","src/Worker/WorkflowCommand.php:910","src/Worker.php:1920,4534"],"fields":["start input","schedule action.input","child/continue commands[].arguments","task.arguments"],"inline":"inline_envelope","external":"external_runtime_reference"}, + {"binding":"sdk-rust","anchors":["src/lib.rs:3320,16660,12242,17036"],"fields":["start input","child/continue commands[].arguments","task.arguments"],"inline":"inline_envelope","external":"external_runtime_reference"}, + {"binding":"workflow","anchors":["src/V2/WorkflowStub.php:1872,2158","src/V2/Support/WorkflowExecutor.php:2921,3932"],"fields":["workflow_runs.arguments/payload_codec","workflow_commands.payload","child/continued workflow_runs.arguments"],"inline":"bare_inline_value","external":"external_embedded_reference"}, + {"binding":"server","anchors":["rust/src/runtime/execution.rs::Runtime::start","rust/src/runtime/store.rs::Store::start_in","rust/src/runtime/schedule_storage.rs::Store::create_schedule","rust/src/runtime/schedule_execution.rs::Store::start_scheduled","rust/src/runtime/children.rs::Store::start_child","rust/src/runtime/continuations.rs::Store::continue_run"],"fields":["direct input","schedule action.input","child/continue commands[].arguments","task.arguments","workflow_runs.arguments/payload_codec","workflow_commands.payload","workflow_schedules.action.input","ScheduleCreated payload.action.input"],"inline":"inline_envelope","external":"external_native_reference"} + ], + "notes": "Direct, scheduled, child and continue-as-new starts share one AAD kind. SDK diagnostic labels map to workflow_input; they are not AAD themselves. Native offload is path-specific and does not create a uniform WorkflowStarted.arguments field." + }, + { + "name": "workflow result", "status": "protected", "boundary_kind": "workflow_result", "namespace_authority": "owning workflow run namespace", + "field_mappings": [ + {"binding":"sdk-python","anchors":["src/durable_workflow/workflow.py:1000,3248","src/durable_workflow/client.py:4109,5358"],"fields":["complete_workflow result","describe output_envelope","WorkflowCompleted payload.output","ChildRunCompleted payload.result/output"],"inline":"inline_envelope","external":"external_runtime_reference"}, + {"binding":"sdk-php","anchors":["src/Worker/Replayer.php:3955,3502","src/Client.php:3388"],"fields":["complete_workflow commands[].result","terminal payload.output/result","child result"],"inline":"inline_envelope","external":"external_runtime_reference"}, + {"binding":"sdk-rust","anchors":["src/lib.rs:10290,6919,6584,17153"],"fields":["complete_workflow commands[].result","describe output_envelope/output","WorkflowCompleted payload.output","ChildRunCompleted payload.result/output"],"inline":"inline_envelope","external":"external_runtime_reference"}, + {"binding":"workflow","anchors":["src/V2/Support/WorkflowExecutor.php:4387","src/V2/WorkflowStub.php"],"fields":["workflow_runs.output/output_payload_codec","WorkflowCompleted payload.output/payload_codec"],"inline":"bare_inline_value","external":"external_embedded_reference"}, + {"binding":"server","anchors":["rust/src/runtime/store.rs::Store::complete_or_checkpoint_in","rust/src/runtime/children.rs::Store::resolve_child","rust/src/runtime/run_describe.rs","rust/src/runtime/history_presentation.rs"],"fields":["complete_workflow result","workflow_runs.output","WorkflowCompleted payload.output","ChildRunCompleted payload.output/result","describe output_envelope"],"inline":"inline_envelope","external":"external_native_reference"} + ] + }, + { + "name": "activity input", "status": "protected", "boundary_kind": "activity_input", "namespace_authority": "owning workflow run namespace", + "field_mappings": [ + {"binding":"sdk-python","anchors":["src/durable_workflow/workflow.py:717","src/durable_workflow/worker.py:3516"],"fields":["schedule_activity arguments","task.arguments"],"inline":"inline_envelope","external":"external_runtime_reference"}, + {"binding":"sdk-php","anchors":["src/Worker/WorkflowCommand.php:910","src/Worker.php:3335,4534"],"fields":["schedule_activity commands[].arguments","task.arguments"],"inline":"inline_envelope","external":"external_runtime_reference"}, + {"binding":"sdk-rust","anchors":["src/lib.rs:15853,17036"],"fields":["schedule_activity arguments","task.arguments"],"inline":"inline_envelope","external":"external_runtime_reference"}, + {"binding":"workflow","anchors":["src/V2/Support/WorkflowExecutor.php:2686","src/V2/Support/ActivitySnapshot.php:52"],"fields":["activity_executions.arguments/payload_codec","ActivityScheduled payload.activity.arguments"],"inline":"bare_inline_value","external":"external_embedded_reference"}, + {"binding":"server","anchors":["rust/src/runtime/store.rs::Store::apply_non_terminal","rust/src/runtime/store.rs::Store::activity_payload_with_completion"],"fields":["schedule_activity arguments","activity_executions.arguments","ActivityScheduled payload.activity.arguments","activity task arguments"],"inline":"inline_envelope","external":"external_native_reference"} + ], + "notes": "Only ordinary durable activity tasks are admitted; local, prepared and other external tasks remain unsupported." + }, + { + "name": "activity result", "status": "protected", "boundary_kind": "activity_result", "namespace_authority": "owning workflow run namespace", + "field_mappings": [ + {"binding":"sdk-python","anchors":["src/durable_workflow/client.py:6789","src/durable_workflow/workflow.py:3248"],"fields":["activity completion result","ActivityCompleted payload.result/output"],"inline":"inline_envelope","external":"external_runtime_reference"}, + {"binding":"sdk-php","anchors":["src/Client.php:2645-2684","src/Worker/Replayer.php:1870,3502"],"fields":["activity completion result","ActivityCompleted payload.result"],"inline":"inline_envelope","external":"external_runtime_reference"}, + {"binding":"sdk-rust","anchors":["src/lib.rs:5026,18934"],"fields":["activity completion result","ActivityCompleted payload.result/payload_codec"],"inline":"inline_envelope","external":"external_runtime_reference"}, + {"binding":"workflow","anchors":["src/V2/Support/ActivityOutcomeRecorder.php:235,342"],"fields":["activity_executions.result/payload_codec","ActivityCompleted payload.result","ActivityCompleted payload.activity.result"],"inline":"bare_inline_value","external":"external_embedded_reference"}, + {"binding":"server","anchors":["rust/src/runtime/execution.rs::Runtime::complete_activity","rust/src/runtime/store.rs::Store::complete_activity_in","rust/src/runtime/activity_completion_uploads.rs","rust/src/runtime/activity_results.rs"],"fields":["activity completion result","activity_executions.result/payload_codec","ActivityCompleted payload.result","result_envelope"],"inline":"inline_envelope","external":"external_native_reference"} + ] + }, + { + "name": "signal input", "status": "protected", "boundary_kind": "signal_input", "namespace_authority": "target workflow namespace", + "field_mappings": [ + {"binding":"sdk-python","anchors":["src/durable_workflow/client.py:4873,4917","src/durable_workflow/workflow.py:3274"],"fields":["signal input","SignalReceived/SignalApplied value/input/arguments"],"inline":"inline_envelope","external":"external_runtime_reference"}, + {"binding":"sdk-php","anchors":["src/Client.php:1065","src/Worker/WorkflowContext.php:887"],"fields":["signal input","history value/input/arguments"],"inline":"inline_envelope","external":"external_runtime_reference"}, + {"binding":"sdk-rust","anchors":["src/lib.rs:3479,17046,19577"],"fields":["signal input","task signal_arguments","history value/input/arguments"],"inline":"inline_envelope","external":"external_runtime_reference"}, + {"binding":"workflow","anchors":["src/V2/WorkflowStub.php:4572,3449"],"fields":["workflow_signals.arguments/payload_codec","workflow_commands.payload","SignalReceived payload.arguments/payload_codec"],"inline":"bare_inline_value","external":"external_embedded_reference"}, + {"binding":"server","anchors":["rust/src/runtime/execution.rs::Runtime::signal_command","rust/src/runtime/store.rs::Store::signal/record_accepted_signal/apply_task_signal","rust/src/runtime/signal_payloads.rs"],"fields":["signal input","workflow_signal_records.arguments","workflow_commands.payload","SignalReceived payload.arguments","SignalApplied payload.value"],"inline":"inline_envelope","external":"external_native_reference"} + ], + "notes": "The pinned Python, PHP and Rust SDKs have no workflow-originated cross-workflow signal helper; this row does not claim that missing capability." + }, + { + "name": "update input", "status": "protected", "boundary_kind": "update_input", "namespace_authority": "target workflow namespace", + "field_mappings": [ + {"binding":"sdk-python","anchors":["src/durable_workflow/client.py:5157","src/durable_workflow/workflow.py:3304"],"fields":["update input","accepted-history/task arguments"],"inline":"inline_envelope","external":"external_runtime_reference"}, + {"binding":"sdk-php","anchors":["src/Client.php:1265","src/Worker.php:3902"],"fields":["update input","accepted-history/task arguments"],"inline":"inline_envelope","external":"external_runtime_reference"}, + {"binding":"sdk-rust","anchors":["src/lib.rs:3866,10488"],"fields":["update input","accepted-history/task arguments"],"inline":"inline_envelope","external":"external_runtime_reference"}, + {"binding":"workflow","anchors":["src/V2/WorkflowStub.php:2857,2927","src/V2/Support/WorkflowExecutor.php:6727"],"fields":["workflow_updates.arguments/payload_codec","UpdateAccepted payload.arguments","UpdateApplied payload.arguments"],"inline":"bare_inline_value","external":null}, + {"binding":"server","anchors":["rust/src/runtime/execution.rs::Runtime::update_command","rust/src/runtime/updates.rs::Store::update_workflow","rust/src/runtime/update_validation.rs"],"fields":["update input","worker/update arguments","workflow_updates.arguments","workflow_commands.payload","UpdateAccepted/UpdateApplied payload.arguments"],"inline":"inline_envelope","external":null} + ], + "notes": "The embedded Workflow producer admits update input inline only; do not infer symmetric offload from update result." + }, + { + "name": "update result", "status": "protected", "boundary_kind": "update_result", "namespace_authority": "owning workflow run namespace", + "field_mappings": [ + {"binding":"sdk-python","anchors":["src/durable_workflow/workflow.py:1071","src/durable_workflow/client.py:2959,5190"],"fields":["complete_update result","receipt result_envelope","retained result projection"],"inline":"inline_envelope","external":"external_runtime_reference"}, + {"binding":"sdk-php","anchors":["src/Worker.php:3903","src/Client.php:1170,3376"],"fields":["complete_update commands[].result","result_envelope","raw result fallback"],"inline":"inline_envelope","external":"external_runtime_reference"}, + {"binding":"sdk-rust","anchors":["src/lib.rs:10510","src/update_outcome.rs:185"],"fields":["complete_update result","receipt/outcome result_envelope","raw result fallback"],"inline":"inline_envelope","external":"external_runtime_reference"}, + {"binding":"workflow","anchors":["src/V2/Support/WorkflowExecutor.php:6746","src/V2/Models/WorkflowUpdate.php:120"],"fields":["workflow_updates.result","UpdateCompleted payload.result"],"inline":"bare_inline_value","external":"external_embedded_reference"}, + {"binding":"server","anchors":["rust/src/runtime/updates.rs::Store::apply_update","rust/src/runtime/terminal_updates.rs","rust/src/runtime/update_summary.rs"],"fields":["complete_update result","workflow_updates.result","UpdateCompleted payload.result","result_envelope/result response"],"inline":"inline_envelope","external":null} + ], + "strict_notes": "Suppress every plaintext result projection and raw fallback, and require authenticated envelope decoding; current readers can otherwise bypass the envelope when result is non-null." + }, + { + "name": "query input", "status": "protected", "boundary_kind": "query_input", "namespace_authority": "target workflow namespace", + "field_mappings": [ + {"binding":"sdk-python","anchors":["src/durable_workflow/client.py:4967","src/durable_workflow/worker.py:3921"],"fields":["query input","query task arguments"],"inline":"inline_envelope","external":"external_runtime_reference"}, + {"binding":"sdk-php","anchors":["src/Client.php:1103","src/Worker.php:3674"],"fields":["query input","task.query_arguments/task.arguments"],"inline":"inline_envelope","external":"external_runtime_reference"}, + {"binding":"sdk-rust","anchors":["src/lib.rs:3783","src/lib.rs::Worker::execute_query_task_with_authority"],"fields":["query input","query task arguments"],"inline":"inline_envelope","external":"external_runtime_reference"}, + {"binding":"workflow","anchors":["src/V2/WorkflowStub.php:820","src/V2/Support/QueryStateReplayer.php:34"],"fields":["in-memory validated PHP arguments"],"inline":"plain_json","external":null}, + {"binding":"server","anchors":["rust/src/runtime/execution.rs::Runtime::query_workflow","rust/src/runtime/queries.rs::Store::query_workflow","rust/src/runtime/query_poll_requests.rs"],"fields":["query input","dw_query_cache.value.query_arguments","query claim query_arguments"],"inline":"inline_envelope","external":null} + ], + "notes": "The embedded Workflow binding is in-memory read-only replay with no durable query-task input field; native query-task transport is distinct." + }, + { + "name": "query result", "status": "protected", "boundary_kind": "query_result", "namespace_authority": "owning workflow run namespace", + "field_mappings": [ + {"binding":"sdk-python","anchors":["src/durable_workflow/client.py:6591,6625,2959"],"fields":["complete_query_task result_envelope","plaintext result projection","response raw fallback"],"inline":"inline_envelope","external":"external_runtime_reference"}, + {"binding":"sdk-php","anchors":["src/Client.php:2865,3376"],"fields":["completeQueryTask result_envelope","plaintext result projection","response raw fallback"],"inline":"inline_envelope","external":"external_runtime_reference"}, + {"binding":"sdk-rust","anchors":["src/lib.rs:4531,3762,3750"],"fields":["complete query result_envelope","plaintext result projection","Value-reader raw fallback"],"inline":"inline_envelope","external":"external_runtime_reference"}, + {"binding":"workflow","anchors":["src/V2/Support/QueryStateReplayer.php:40","src/V2/Support/QueryResponse.php:68"],"fields":["in-memory PHP return value","HTTP result"],"inline":"plain_json","external":null}, + {"binding":"server","anchors":["rust/src/runtime/queries.rs::Store::finish_query/query_outcome","rust/src/runtime/execution.rs::complete_query"],"fields":["worker result_envelope","plaintext result alias","dw_query_cache.value.result_envelope/result","query response result_envelope/result"],"inline":"inline_envelope","external":null} + ], + "strict_notes": "Omit or refuse the JSON-compatible plaintext result and every raw fallback. Encrypting only result_envelope leaks the result and permits a strict-reader bypass." + }, + { + "name": "side-effect result", "status": "protected", "boundary_kind": "side_effect_result", "namespace_authority": "owning workflow run namespace", + "field_mappings": [ + {"binding":"sdk-python","anchors":["src/durable_workflow/workflow.py:1177,554,3248","src/durable_workflow/client.py:2536"],"fields":["record_side_effect result","SideEffectRecorded payload.result"],"inline":"bare_inline_value","external":"external_runtime_reference"}, + {"binding":"sdk-php","anchors":["src/Worker/WorkflowCommand.php:917","src/Worker/Replayer.php:3502"],"fields":["record_side_effect commands[].result","SideEffectRecorded payload.result"],"inline":"bare_inline_value","external":"external_runtime_reference"}, + {"binding":"sdk-rust","anchors":["src/lib.rs:11674","recorded command/history readers"],"fields":["record_side_effect result","SideEffectRecorded payload.result"],"inline":"bare_inline_value","external":"external_runtime_reference"}, + {"binding":"workflow","anchors":["src/V2/Support/WorkflowExecutor.php:5582"],"fields":["SideEffectRecorded payload.result"],"inline":"bare_inline_value","external":"external_embedded_reference"}, + {"binding":"server","anchors":["rust/src/runtime/mod.rs::side_effect_result","rust/src/runtime/store.rs::Store::apply_non_terminal"],"fields":["record_side_effect result/payload_codec","SideEffectRecorded payload.result"],"inline":"bare_inline_value","external":null} + ], + "notes": "The inline field is a bare base64 Value string in several bindings, not uniformly {codec,blob}." + }, + { + "name": "external payload object for a supported boundary", "status": "protected", "boundary_kind": "inherited", "namespace_authority": "inherited from the logical boundary", + "field_mappings": [ + {"binding":"sdk-python","anchors":["src/durable_workflow/serializer.py:287,448","src/durable_workflow/client.py:2536,2570,2790"],"fields":["external object body","external_payload size/hash"],"inline":"protected_external_object","external":"external_runtime_reference"}, + {"binding":"sdk-php","anchors":["src/Transport/RuntimePayloadUploads.php:296,426,586","src/Transport/RuntimePayloads.php:150"],"fields":["external object body","external_payload size/hash"],"inline":"protected_external_object","external":"external_runtime_reference"}, + {"binding":"sdk-rust","anchors":["src/runtime_uploads.rs:149","src/runtime_payloads.rs:34"],"fields":["external object body","external_payload size/hash"],"inline":"protected_external_object","external":"external_runtime_reference"}, + {"binding":"workflow","anchors":["src/V2/Support/ExternalPayloads.php:17,36,143,161","src/V2/Support/ExternalPayloadStorage.php:26,38"],"fields":["stored object bytes","DB external reference","history external_storage reference"],"inline":"protected_external_object","external":"external_embedded_reference"}, + {"binding":"server","anchors":["rust/src/runtime/external_resolution.rs::PreparedExternal::blob/stored/stored_envelope","rust/src/runtime/external_registry.rs","rust/src/runtime/external_transport.rs"],"fields":["runtime_external_payloads metadata","UTF-8 Avro blob object bytes","external_payload reference","dw-native-external-payload-v1 persisted text"],"inline":"protected_external_object","external":"external_native_reference"} + ], + "notes": "Protect before size/offload selection. Size and SHA-256 cover ASCII base64 object bytes; verify before strict base64 decoding and decryption. Storage does not change the inherited AAD kind." + }, + { + "name": "activity heartbeat details", "status": "unsupported_refuse", "boundary_kind": null, "namespace_authority": "none until a portable envelope replaces raw JSON", + "field_mappings": [ + {"binding":"sdk-python","anchors":["src/durable_workflow/client.py:7050"],"fields":["heartbeat body details"],"representation":"plain_json"}, + {"binding":"sdk-php","anchors":["src/Client.php:2735","src/Worker.php:4133"],"fields":["heartbeat body details"],"representation":"plain_json"}, + {"binding":"sdk-rust","anchors":["src/lib.rs::Client::heartbeat_activity_task_value","src/lib.rs::ActivityContext::heartbeat"],"fields":["heartbeat body details"],"representation":"plain_json"}, + {"binding":"workflow","anchors":["src/V2/Activity.php:109","src/V2/Support/ActivityHeartbeatHistory.php:37","src/V2/Support/HeartbeatProgress.php:23"],"fields":["activity_attempts.last_heartbeat_progress","ActivityHeartbeatRecorded payload.progress"],"representation":"plain_json"}, + {"binding":"server","anchors":["rust/src/runtime/activity_heartbeats.rs"],"fields":["heartbeat details/progress"],"representation":"plain_json"} + ] + }, + { + "name": "workflow message stream item", "status": "unsupported_refuse", "boundary_kind": null, "namespace_authority": "none until stream framing and replay semantics are specified", + "field_mappings": [ + {"binding":"sdk-python","anchors":["src/durable_workflow/client.py:4526,4943","src/durable_workflow/workflow.py:2638"],"fields":["items[].payload","stream input","workflow_stream.items[].payload"],"representation":"mixed Value envelopes/references"}, + {"binding":"sdk-php","anchors":["src/Client.php:941,1086","src/Model/WorkflowStreamAppendItem.php:22"],"fields":["items[].payload/payload_reference","stream input"],"representation":"mixed Value envelopes/references"}, + {"binding":"sdk-rust","anchors":["src/lib.rs::Client::append_message_stream","src/lib.rs::WorkflowStreamAppendItem"],"fields":["input","items[].payload/payload_envelope/opaque reference"],"representation":"mixed Value envelopes/references"}, + {"binding":"workflow","anchors":["src/V2/MessageStream.php:159","src/V2/Support/MessageService.php:99"],"fields":["workflow_messages.payload_reference","JSON metadata"],"representation":"separate referenced bytes"}, + {"binding":"server","anchors":["rust/src/runtime/message_streams.rs","rust/src/runtime/workflow_streams.rs"],"fields":["stream item payload/payload_reference"],"representation":"mixed Value envelopes/references"} + ] + }, + { + "name": "Nexus request or result", "status": "unsupported_refuse", "boundary_kind": null, "namespace_authority": "none until caller and target namespace binding is specified", + "field_mappings": [ + {"binding":"sdk-python","anchors":["src/durable_workflow/client.py:3318,3356","src/durable_workflow/workflow.py:1371,3051"],"fields":["service arguments","request_payload","response_payload"],"representation":"Value envelopes/references"}, + {"binding":"sdk-php","anchors":["src/Client.php:3488","src/Worker/WorkflowCommand.php:906"],"fields":["arguments/payload_codec","request_payload"],"representation":"bare Value or envelope"}, + {"binding":"sdk-rust","anchors":["src/nexus.rs::NexusServiceCall::poll_avro_value"],"fields":["request_payload","response_payload"],"representation":"Value envelopes/references"}, + {"binding":"workflow","anchors":["src/V2/Support/WorkflowExecutor.php:6135,6332","src/V2/Support/ServiceCallWorkflowDelivery.php:243"],"fields":["request_payload","response_payload","response_or_failure_surface"],"representation":"Value envelopes/references"}, + {"binding":"server","anchors":["rust/src/runtime/nexus_operations.rs","rust/src/runtime/service_calls.rs"],"fields":["request_payload","response_payload"],"representation":"Value envelopes/references"} + ] + }, + { + "name": "local or prepared activity payload", "status": "unsupported_refuse", "boundary_kind": null, "namespace_authority": "none until every local/prepared persistence path uses the portable envelope", + "field_mappings": [ + {"binding":"sdk-python","anchors":["src/durable_workflow/workflow.py:861,889"],"fields":["local arguments/result/marker"],"representation":"Value envelopes/references"}, + {"binding":"sdk-php","anchors":["src/Worker/WorkflowCommand.php:473","src/Worker/PreparedLocalActivityRunner.php:154"],"fields":["descriptor.arguments","report.result"],"representation":"Value envelopes/references"}, + {"binding":"sdk-rust","anchors":["src/lib.rs::ActivityCall","src/local_activity.rs","src/runtime_uploads.rs::payload_paths"],"fields":["local arguments/results/heartbeat reports"],"representation":"Value envelopes/references"}, + {"binding":"workflow","anchors":["src/V2/Support/LocalActivityExecutor.php:746,1041,1281"],"fields":["local execution arguments/result","ActivityCompleted payload"],"representation":"codec blobs/external references"}, + {"binding":"server","anchors":["rust/src/runtime/local_activities.rs","rust/src/runtime/prepared_local.rs"],"fields":["local/prepared arguments/result/report"],"representation":"Value envelopes/references"} + ] + }, + { + "name": "external task payload outside ordinary activity envelope", "status": "unsupported_refuse", "boundary_kind": null, "namespace_authority": "none until proven to reuse an admitted activity boundary", + "field_mappings": [ + {"binding":"workflow","anchors":["src/V2/Support/ExternalTaskInput.php:41"],"fields":["payloads[] transport objects"],"representation":"unproven task-specific transport"}, + {"binding":"server","anchors":["rust/src/runtime/external_transport.rs"],"fields":["unmapped external task body"],"representation":"unproven task-specific transport"} + ] + }, + { + "name": "failure details", "status": "unsupported_refuse", "boundary_kind": null, "namespace_authority": "none until every producer/reader shares one portable envelope", + "field_mappings": [ + {"binding":"sdk-python","anchors":["src/durable_workflow/client.py:6845"],"fields":["failure.details","message/type/stack"],"representation":"optional Value details plus plaintext metadata"}, + {"binding":"sdk-php","anchors":["src/Client.php:2706"],"fields":["failure.details","message/type/stack/diagnostics"],"representation":"optional Value details plus plaintext metadata"}, + {"binding":"sdk-rust","anchors":["src/lib.rs::Client::fail_activity_task_with_failure","src/lib.rs::Client::fail_workflow_task_with_failure","src/failure_reporting.rs"],"fields":["failure details","message/type/stack"],"representation":"optional Value details plus plaintext metadata"}, + {"binding":"workflow","anchors":["src/V2/Support/FailureFactory.php:46","src/V2/Models/WorkflowFailure.php:15","src/V2/Support/ActivityOutcomeRecorder.php:265"],"fields":["workflow_failures fields","activity exception snapshot"],"representation":"plaintext fields plus selected codec snapshot"}, + {"binding":"server","anchors":["rust/src/runtime/activity_failures.rs","rust/src/runtime/task_failures.rs","rust/src/runtime/workflow_failures.rs"],"fields":["failure details","message/type/stack"],"representation":"optional Value details plus plaintext metadata"} + ], + "notes": "Messages, stacks and exception types remain plaintext metadata; v1 must not partially protect an ad hoc details field." + }, + { + "name": "raw JSON or generic binary payload", "status": "unsupported_refuse", "boundary_kind": null, "namespace_authority": "none", + "field_mappings": [ + {"binding":"sdk-python","anchors":["src/durable_workflow/serializer.py:193","HTTP document transport"],"fields":["untyped raw JSON/binary body"],"representation":"plain_json"}, + {"binding":"sdk-php","anchors":["src/Client.php:2959","src/Codec/AvroPayloadCodec.php:57"],"fields":["untyped raw JSON/binary body"],"representation":"plain_json"}, + {"binding":"sdk-rust","anchors":["src/raw_request.rs:104","Client::send_raw_request/control_plane_request/worker_plane_request"],"fields":["raw body bytes/JSON"],"representation":"uninterpreted raw transport"}, + {"binding":"workflow","anchors":["JSON history/task metadata and arbitrary HTTP bodies"],"fields":["untyped raw JSON/binary body"],"representation":"plain_json"}, + {"binding":"server","anchors":["rust/src/runtime/json_body.rs","rust/src/runtime/http.rs"],"fields":["untyped raw JSON/binary body"],"representation":"plain_json"} + ], + "notes": "Typed bytes inside an admitted Avro Value remain legitimate. Protection never wraps an untyped transport body as fallback." + } + ], + "plaintext_metadata": [ + "workflow, run, task, command, attempt and request identifiers", + "workflow, activity, signal, update and query names", + "task queues and namespaces", + "memo, search attributes and visibility labels", + "failure message, stack and exception type", + "deadlines, retry policy and status fields" + ] +} diff --git a/conformance/payload-protection/protected-value-v1.avsc b/conformance/payload-protection/protected-value-v1.avsc new file mode 100644 index 0000000..8206e62 --- /dev/null +++ b/conformance/payload-protection/protected-value-v1.avsc @@ -0,0 +1,27 @@ +{ + "type": "record", + "name": "ProtectedValue", + "namespace": "durable_workflow.protocol", + "fields": [ + { + "name": "dw_protected", + "type": "int" + }, + { + "name": "alg", + "type": "string" + }, + { + "name": "key_id", + "type": "string" + }, + { + "name": "nonce", + "type": "bytes" + }, + { + "name": "ciphertext", + "type": "bytes" + } + ] +} diff --git a/conformance/payload-protection/vectors-v1.json b/conformance/payload-protection/vectors-v1.json new file mode 100644 index 0000000..87acd8c --- /dev/null +++ b/conformance/payload-protection/vectors-v1.json @@ -0,0 +1,88 @@ +{ + "contract": "durable-workflow.payload-protection/v1", + "protected_value_schema_fingerprint_hex": "0ba70c3fd6c311ca", + "plain_value_schema_fingerprint_hex": "e2a33dff55802237", + "algorithm": "xchacha20poly1305-ietf", + "key_id_pattern": "^[A-Za-z0-9._:-]{1,128}$", + "aad_prefix_hex": "64757261626c652d776f726b666c6f772e7061796c6f61642d70726f74656374696f6e00", + "vectors": [ + { + "name": "workflow-input-unicode-boundaries", + "namespace": "tenant-a", + "boundary_kind": "workflow_input", + "key_id": "vector-key-1", + "key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "nonce_hex": "000102030405060708090a0b0c0d0e0f1011121314151617", + "value_json": { + "i": -9223372036854775808, + "m": { + "k": "v" + }, + "n": null, + "u": "Iñtërnâtiônàlizætiøn ☃ 𐍈" + }, + "aad_hex": "64757261626c652d776f726b666c6f772e7061796c6f61642d70726f74656374696f6e000100000016786368616368613230706f6c79313330352d696574660000000c766563746f722d6b65792d310000000874656e616e742d610000000e776f726b666c6f775f696e707574", + "inner_plain_value_hex": "c301e2a33dff558022370e08026904ffffffffffffffffff01026d0e02026b0a027600026e0002750a4849c3b174c3ab726ec3a27469c3b46ec3a06c697ac3a67469c3b86e20e2988320f0908d8800", + "ciphertext_and_tag_hex": "5dc3eddcad2dd82e117328c6c93bac17b4b8d75a0229e2147e1852b83faf9e535c70b6e62f670b11dfdde79a7e5590c903d421fb331d38ad69583351e69d8752c6493c0b66b9861daf7fd651d6ed097dd23b12f1a5be86cc6dd24eecb9163a", + "outer_single_object_base64": "wwELpww/1sMRygIseGNoYWNoYTIwcG9seTEzMDUtaWV0Zhh2ZWN0b3Ita2V5LTEwAAECAwQFBgcICQoLDA0ODxAREhMUFRYXvgFdw+3crS3YLhFzKMbJO6wXtLjXWgIp4hR+GFK4P6+eU1xwtuYvZwsR393nmn5VkMkD1CH7Mx04rWlYM1HmnYdSxkk8C2a5hh2vf9ZR1u0JfdI7EvGlvobMbdJO7LkWOg==", + "decoded_outer_single_object_bytes": 169, + "external_object_encoding": "ascii-base64-no-whitespace", + "external_object_ascii_bytes": 228, + "external_object_sha256": "2647b8aa410b36f1de317bf0946052967585f79d7ea025e6fc9f2efc3967c6d3" + } + ], + "required_negative_cases": [ + { + "name": "ordinary-map-with-protection-like-keys", + "result": "plain_value" + }, + { + "name": "protected-version-not-one", + "result": "payload_envelope_unsupported" + }, + { + "name": "protected-algorithm-not-exact", + "result": "payload_envelope_unsupported" + }, + { + "name": "key-id-outside-closed-grammar", + "result": "payload_envelope_unsupported" + }, + { + "name": "nonce-length-not-24", + "result": "payload_envelope_unsupported" + }, + { + "name": "ciphertext-shorter-than-tag", + "result": "payload_envelope_unsupported" + }, + { + "name": "unknown-key-id", + "result": "payload_key_unavailable" + }, + { + "name": "namespace-substitution", + "result": "payload_authentication_failed" + }, + { + "name": "boundary-kind-substitution", + "result": "payload_authentication_failed" + }, + { + "name": "ciphertext-bit-flip", + "result": "payload_authentication_failed" + }, + { + "name": "authenticated-inner-value-with-wrong-fingerprint", + "result": "payload_plaintext_invalid" + }, + { + "name": "protected-envelope-seen-without-enabled-reader", + "result": "payload_envelope_unsupported" + }, + { + "name": "plain-value-at-strict-enabled-boundary", + "result": "payload_protection_required" + } + ] +} diff --git a/tests/test_payload_protection_contract.py b/tests/test_payload_protection_contract.py new file mode 100644 index 0000000..e084b49 --- /dev/null +++ b/tests/test_payload_protection_contract.py @@ -0,0 +1,289 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +import re +import struct +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +CONTRACT_ROOT = ROOT / "conformance" / "payload-protection" + + +def load(name: str) -> dict[str, object]: + return json.loads((CONTRACT_ROOT / name).read_text(encoding="utf-8")) + + +def lp(value: str) -> bytes: + encoded = value.encode("utf-8") + return struct.pack(">I", len(encoded)) + encoded + + +def aad(*, algorithm: str, key_id: str, namespace: str, boundary_kind: str) -> bytes: + return b"".join( + ( + b"durable-workflow.payload-protection\0", + b"\x01", + lp(algorithm), + lp(key_id), + lp(namespace), + lp(boundary_kind), + ) + ) + + +class PayloadProtectionContractTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.schema = load("protected-value-v1.avsc") + cls.vectors = load("vectors-v1.json") + cls.call_sites = load("call-sites-v1.json") + + def test_schema_is_the_closed_protected_value_record(self) -> None: + self.assertEqual("record", self.schema["type"]) + self.assertEqual("ProtectedValue", self.schema["name"]) + self.assertEqual("durable_workflow.protocol", self.schema["namespace"]) + self.assertEqual( + [ + ("dw_protected", "int"), + ("alg", "string"), + ("key_id", "string"), + ("nonce", "bytes"), + ("ciphertext", "bytes"), + ], + [(field["name"], field["type"]) for field in self.schema["fields"]], + ) + self.assertNotEqual( + self.vectors["protected_value_schema_fingerprint_hex"], + self.vectors["plain_value_schema_fingerprint_hex"], + ) + + def test_canonical_aad_and_lengths(self) -> None: + vector = self.vectors["vectors"][0] + actual = aad( + algorithm=self.vectors["algorithm"], + key_id=vector["key_id"], + namespace=vector["namespace"], + boundary_kind=vector["boundary_kind"], + ) + self.assertEqual(vector["aad_hex"], actual.hex()) + self.assertEqual(self.vectors["aad_prefix_hex"], actual[:36].hex()) + self.assertEqual(32, len(bytes.fromhex(vector["key_hex"]))) + self.assertEqual(24, len(bytes.fromhex(vector["nonce_hex"]))) + self.assertGreaterEqual(len(bytes.fromhex(vector["ciphertext_and_tag_hex"])), 16) + self.assertRegex(vector["key_id"], re.compile(self.vectors["key_id_pattern"])) + + def test_outer_object_and_external_identity_are_exact(self) -> None: + vector = self.vectors["vectors"][0] + outer_ascii = vector["outer_single_object_base64"].encode("ascii") + outer = base64.b64decode(outer_ascii, validate=True) + + self.assertEqual( + "ascii-base64-no-whitespace", vector["external_object_encoding"] + ) + self.assertEqual(vector["decoded_outer_single_object_bytes"], len(outer)) + self.assertEqual(vector["external_object_ascii_bytes"], len(outer_ascii)) + self.assertEqual( + vector["external_object_sha256"], hashlib.sha256(outer_ascii).hexdigest() + ) + self.assertEqual(b"\xc3\x01", outer[:2]) + self.assertEqual( + bytes.fromhex(self.vectors["protected_value_schema_fingerprint_hex"]), + outer[2:10], + ) + self.assertIn(bytes.fromhex(vector["nonce_hex"]), outer) + self.assertTrue(outer.endswith(bytes.fromhex(vector["ciphertext_and_tag_hex"]))) + + def test_inner_plain_value_uses_the_existing_fingerprint(self) -> None: + vector = self.vectors["vectors"][0] + inner = bytes.fromhex(vector["inner_plain_value_hex"]) + self.assertEqual(b"\xc3\x01", inner[:2]) + self.assertEqual( + bytes.fromhex(self.vectors["plain_value_schema_fingerprint_hex"]), + inner[2:10], + ) + self.assertEqual( + -9223372036854775808, + vector["value_json"]["i"], + ) + self.assertEqual("Iñtërnâtiônàlizætiøn ☃ 𐍈", vector["value_json"]["u"]) + + def test_negative_cases_cover_recognition_context_and_precedence(self) -> None: + cases = { + case["name"]: case["result"] + for case in self.vectors["required_negative_cases"] + } + self.assertEqual("plain_value", cases["ordinary-map-with-protection-like-keys"]) + self.assertEqual( + "payload_envelope_unsupported", cases["protected-version-not-one"] + ) + self.assertEqual("payload_key_unavailable", cases["unknown-key-id"]) + self.assertEqual( + "payload_authentication_failed", cases["namespace-substitution"] + ) + self.assertEqual( + "payload_authentication_failed", cases["boundary-kind-substitution"] + ) + self.assertEqual( + "payload_plaintext_invalid", + cases["authenticated-inner-value-with-wrong-fingerprint"], + ) + self.assertEqual( + "payload_protection_required", + cases["plain-value-at-strict-enabled-boundary"], + ) + self.assertEqual(13, len(cases)) + + def test_call_site_inventory_is_closed_and_source_bound(self) -> None: + self.assertEqual( + {"sdk-python", "sdk-php", "sdk-rust", "workflow", "server"}, + set(self.call_sites["source_revisions"]), + ) + for name, revision in self.call_sites["source_revisions"].items(): + with self.subTest(name=name): + self.assertRegex(revision, r"^[0-9a-f]{40}$") + + boundaries = self.call_sites["boundaries"] + names = [boundary["name"] for boundary in boundaries] + self.assertEqual(len(names), len(set(names))) + self.assertEqual( + {"protected", "unsupported_refuse"}, + {boundary["status"] for boundary in boundaries}, + ) + + supported = { + boundary["boundary_kind"] + for boundary in boundaries + if boundary["status"] == "protected" + and boundary["boundary_kind"] != "inherited" + } + self.assertEqual( + { + "workflow_input", + "workflow_result", + "activity_input", + "activity_result", + "signal_input", + "update_input", + "update_result", + "query_input", + "query_result", + "side_effect_result", + }, + supported, + ) + + unsupported = { + boundary["name"] + for boundary in boundaries + if boundary["status"] == "unsupported_refuse" + } + self.assertTrue( + { + "activity heartbeat details", + "workflow message stream item", + "Nexus request or result", + "local or prepared activity payload", + "failure details", + "raw JSON or generic binary payload", + }.issubset(unsupported) + ) + self.assertTrue(self.call_sites["plaintext_metadata"]) + + def test_every_call_site_has_exact_fields_and_representation(self) -> None: + revisions = set(self.call_sites["source_revisions"]) + representations = set(self.call_sites["representations"]) + self.assertEqual( + { + "inline_envelope", + "bare_inline_value", + "external_runtime_reference", + "external_embedded_reference", + "external_native_reference", + "protected_external_object", + "plain_json", + }, + representations, + ) + + for boundary in self.call_sites["boundaries"]: + with self.subTest(boundary=boundary["name"]): + mappings = boundary["field_mappings"] + self.assertTrue(mappings) + bindings = [mapping["binding"] for mapping in mappings] + self.assertEqual(len(bindings), len(set(bindings))) + self.assertTrue(set(bindings).issubset(revisions)) + for mapping in mappings: + self.assertTrue(mapping["anchors"]) + self.assertTrue(mapping["fields"]) + self.assertTrue( + all(isinstance(value, str) and value for value in mapping["anchors"]) + ) + self.assertTrue( + all(isinstance(value, str) and value for value in mapping["fields"]) + ) + if boundary["status"] == "protected": + self.assertIn(mapping["inline"], representations) + external = mapping["external"] + self.assertTrue(external is None or external in representations) + else: + self.assertIsInstance(mapping["representation"], str) + self.assertTrue(mapping["representation"]) + + protected = [ + boundary + for boundary in self.call_sites["boundaries"] + if boundary["status"] == "protected" + ] + for boundary in protected: + self.assertEqual(revisions, {item["binding"] for item in boundary["field_mappings"]}) + + def test_inventory_closes_plaintext_shadows_and_binding_mistakes(self) -> None: + boundaries = { + boundary["name"]: boundary for boundary in self.call_sites["boundaries"] + } + query = boundaries["query result"] + update = boundaries["update result"] + self.assertIn("plaintext", query["strict_notes"]) + self.assertIn("raw fallback", query["strict_notes"]) + self.assertIn("plaintext", update["strict_notes"]) + self.assertIn("raw", update["strict_notes"]) + + all_anchors = [ + anchor + for boundary in self.call_sites["boundaries"] + for mapping in boundary["field_mappings"] + for anchor in mapping["anchors"] + ] + self.assertFalse(any(anchor.startswith("server:app/") for anchor in all_anchors)) + self.assertFalse(any("Worker.php::queryContext" in anchor for anchor in all_anchors)) + + workflow_query = next( + item for item in boundaries["query input"]["field_mappings"] + if item["binding"] == "workflow" + ) + self.assertEqual("plain_json", workflow_query["inline"]) + self.assertIsNone(workflow_query["external"]) + + native_signal = next( + item for item in boundaries["signal input"]["field_mappings"] + if item["binding"] == "server" + ) + self.assertIn("SignalApplied payload.value", native_signal["fields"]) + self.assertNotIn("SignalApplied payload.arguments", native_signal["fields"]) + + native_external = next( + item + for item in boundaries["external payload object for a supported boundary"]["field_mappings"] + if item["binding"] == "server" + ) + self.assertEqual("external_native_reference", native_external["external"]) + self.assertTrue( + any("dw-native-external-payload-v1" in field for field in native_external["fields"]) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_regression_corpus.py b/tests/test_regression_corpus.py index d90b770..4cc2e7c 100644 --- a/tests/test_regression_corpus.py +++ b/tests/test_regression_corpus.py @@ -11,6 +11,7 @@ from scripts import regression_corpus as corpus from scripts.regression_corpus import CorpusError, validate +from tests import test_payload_protection_contract ROOT = Path(__file__).resolve().parents[1] @@ -734,3 +735,14 @@ def test_published_schemas_accept_the_machine_owned_examples(self) -> None: codec_fixture("avro-value-v1-long-zero", "wwHioz3/VYAiNwQA") ) Draft202012Validator(evidence_schema).validate(replay_fixture("worker-v1-activity-completion")) + + +def load_tests( + loader: unittest.TestLoader, + standard_tests: unittest.TestSuite, + pattern: str | None, +) -> unittest.TestSuite: + standard_tests.addTests( + loader.loadTestsFromModule(test_payload_protection_contract, pattern=pattern) + ) + return standard_tests