From 6d0e041d2121777449dac31ab7e6f2838b75c823 Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Thu, 17 Sep 2026 13:10:31 -0400 Subject: [PATCH] fix(schema): put the spec version at the manifest root and stop rejecting real TDFs json-schema/schema.json declared tdf_spec_version under payload and listed it in payload.required. manifest.md has always documented the spec version as a top-level field, and payload.md never listed it at all, so the schema was the outlier of the three. The required entry made the normative schema reject every TDF anyone writes: against the eight golden containers in opentdf/tests it failed 8/8, each time on `'tdf_spec_version' is a required property`. It now passes 7, matching what platform's copy of this schema accepts. The eighth omits method.isStreamable and fails on platform too; that constraint is left alone here so the two copies stay in step. Changes: * schemaVersion is declared at the root as the canonical name. Nothing declared it before, in any copy of this schema, though it is the name every current SDK writes. * tdf_spec_version is declared at the root as deprecated, and kept under payload as deprecated, typed to admit null. Both positions occur in archival files -- the root because writers followed manifest.md, payload because they followed this schema's error -- so readers have to take either. A new "Spec Version Naming" section in manifest.md states the reader/writer rules. * mimeType leaves payload.required. Platform dropped it in Nov 2024, one day after this schema was forked from it; the fix never came back across. * policyBinding accepts a bare string, keyAccess.type gains ec-wrapped and hybrid-wrapped, and ephemeralPublicKey is declared. These have been in platform's copy since 2025 and describe key access objects that exist. * rootSignature.alg is pinned to HS256. * rootSignature.sig, segments[].hash and policyBinding.hash now state that the pre-4.3.0 hex spelling MUST be accepted indefinitely, and that a reader should tell the two apart by decoded length rather than by the spec version, which nothing authenticates. Requested in platform#3597. Co-Authored-By: Claude Opus 5 Signed-off-by: Paul Flynn --- schema/OpenTDF/json-schema/schema.json | 56 +++++++++++++++++--------- schema/OpenTDF/manifest.md | 26 +++++++++++- 2 files changed, 62 insertions(+), 20 deletions(-) diff --git a/schema/OpenTDF/json-schema/schema.json b/schema/OpenTDF/json-schema/schema.json index b2929ba..769dbc3 100644 --- a/schema/OpenTDF/json-schema/schema.json +++ b/schema/OpenTDF/json-schema/schema.json @@ -5,6 +5,14 @@ "description": "TDF manifest in JSON", "type": "object", "properties": { + "schemaVersion": { + "description": "Semver version number of the OpenTDF specification this manifest conforms to. This is the canonical name for the field and the only name a writer may emit.", + "type": "string" + }, + "tdf_spec_version": { + "description": "Deprecated name for `schemaVersion`. Readers MUST accept it; writers MUST NOT emit it. Retained because archival TDFs carry it.", + "type": "string" + }, "payload": { "type": "object", "description": "An object which contains information describing the payload.", @@ -29,11 +37,11 @@ "type": "string" }, "tdf_spec_version": { - "description": "Semver version number of the TDF spec.", - "type": "string" + "description": "Deprecated. This field belongs at the manifest root, not here; earlier revisions of this schema declared it under `payload` in error. Readers MUST accept it in either position; writers MUST NOT emit it in either. Some archival TDFs carry it here with a null value.", + "type": ["string", "null"] } }, - "required": ["type", "url", "protocol", "isEncrypted","mimeType", "tdf_spec_version"] + "required": ["type", "url", "protocol", "isEncrypted"] }, "encryptionInformation": { "type": "object", @@ -52,7 +60,7 @@ "type": { "description": "The type of key access object.", "type": "string", - "enum": ["wrapped", "remote"] + "enum": ["wrapped", "ec-wrapped", "hybrid-wrapped", "remote"] }, "url": { "description": "A fully qualified URL pointing to a key access service responsible for managing access to the encryption keys.", @@ -76,23 +84,34 @@ "type": "string" }, "policyBinding": { - "description": "Object describing the policyBinding. Contains a hash, and an algorithm used.", - "type": "object", - "properties": { - "alg": { - "description": "The policy binding algorithm used to generate the hash.", + "description": "Object describing the policyBinding. Contains a hash, and an algorithm used. May also be a string, with just the hash. In that case default to HS256.", + "oneOf": [ + { "type": "string" }, - "hash": { - "description": "This contains a keyed hash that will provide cryptographic integrity on the policy object, such that it cannot be modified or copied to another TDF, without invalidating the binding. Specifically, you would have to have access to the key in order to overwrite the policy.", - "type": "string" + { + "type": "object", + "properties": { + "alg": { + "description": "The policy binding algorithm used to generate the hash.", + "type": "string" + }, + "hash": { + "description": "This contains a keyed hash that will provide cryptographic integrity on the policy object, such that it cannot be modified or copied to another TDF, without invalidating the binding. Specifically, you would have to have access to the key in order to overwrite the policy. Base64 over the raw HMAC from spec 4.3.0 onward; pre-4.3.0 writers emit Base64 over its hex. Readers MUST accept both, indefinitely.", + "type": "string" + } + }, + "required": ["alg", "hash"] } - } - ,"required": ["alg", "hash"] + ] }, "encryptedMetadata": { "description": "Metadata associated with the TDF, and the request. The contents of the metadata are freeform, and are used to pass information from the client, and any plugins that may be in use by the KAS. The metadata stored here should not be used for primary access decisions. Base64.", "type": "string" + }, + "ephemeralPublicKey": { + "description": "For ECC wrapped keys, the client public key portion used, with the KAS public key identified with the key id, to derive a shared key that encrypts the wrapped key.", + "type": "string" } } }, @@ -119,11 +138,12 @@ "type": "object", "properties": { "alg": { - "description": "Algorithm used to generate the root signature of the payload", - "type": "string" + "description": "Algorithm used to generate the root signature of the payload. HS256 only: the root signature covers the aggregate hash, which AES-GCM never processes, so a GMAC root has no authentication tag to read back out. An absent alg means HS256.", + "type": "string", + "enum": ["HS256"] }, "sig": { - "description": "The payload signature", + "description": "The payload signature. Base64 over the raw keyed digest from spec 4.3.0 onward; pre-4.3.0 writers emit Base64 over its hex. Readers MUST accept both, indefinitely, and SHOULD determine which is present from the decoded length rather than from the manifest's spec version, which is unauthenticated.", "type": "string" } } @@ -144,7 +164,7 @@ "type": "object", "properties": { "hash": { - "description": "Generated hash using the segment hashing algorithm specified in the parent object.", + "description": "Generated hash using the segment hashing algorithm specified in the parent object. Encoded the same way as `rootSignature.sig`, and subject to the same dual-accept rule.", "type": "string" }, "segmentSize": { diff --git a/schema/OpenTDF/manifest.md b/schema/OpenTDF/manifest.md index c9ce227..851860f 100644 --- a/schema/OpenTDF/manifest.md +++ b/schema/OpenTDF/manifest.md @@ -10,18 +10,40 @@ The manifest object contains the following top-level properties: | Parameter | Type | Description | Required? | | :---------------------- | :----- | :---------------------------------------------------------------------------------------------------------- | :-------- | -| `tdf_spec_version` | String | Semver version number of the OpenTDF specification this manifest conforms to. | Yes | +| `schemaVersion` | String | Semver version number of the OpenTDF specification this manifest conforms to. Absent on pre-4.3.0 containers. | No | +| `tdf_spec_version` | String | Deprecated name for `schemaVersion`. See [Spec Version Naming](#spec-version-naming). | No | | `payload` | Object | Describes the location and characteristics of the encrypted payload. See [Payload Object](./payload.md). | Yes | | `encryptionInformation` | Object | Contains details about encryption, key access, integrity, and policy. See [Encryption Information Object](./encryption_information.md). | Yes | | `assertions` | Array | Optional array of verifiable statements about the TDF or payload. See [Assertions Array](./assertion.md). | No | +## Spec Version Naming + +`schemaVersion` is the canonical name, and the only name a writer may emit. + +`tdf_spec_version` is a deprecated spelling of the same field. It is not a former +name that was renamed on a schedule — it entered some specification drafts and +some older OpenTDF documentation in error, and writers built from those drafts +emitted it. Archival TDFs carry it, so: + +- Readers **MUST** accept `tdf_spec_version`, at the manifest root and under + `payload`, and **MUST** prefer `schemaVersion` when more than one is present. + Revisions of `json-schema/schema.json` before this one declared the field + under `payload` in error, which is why both positions occur in the wild. +- Writers **MUST NOT** emit `tdf_spec_version` in either position. Re-emitting a + manifest normalizes the name rather than propagating it. + +Neither name is authenticated. Readers **MUST NOT** use the spec version to +decide whether a container verifies — see the dual-accept rules on +[`rootSignature.sig`](./integrity_information.md) and +[`policyBinding.hash`](./key_access_object.md). + ## Full Manifest Example This example illustrates a complete `manifest.json` structure. Links point to detailed descriptions of each major section. ```json { - "tdf_spec_version": "1.0.0", + "schemaVersion": "4.3.0", // --- Payload Object --- "payload": { "type": "reference",