CI-TDF

Manifest Specification

ClassifyIt Trusted Data Format

CI-TDF Manifest Profile

Profile: CI-TDF-1.0

This document describes the manifest produced and consumed by this Delphi CI-TDF implementation, developed for ClassifyIt.

What CI-TDF Is

CI-TDF is a self-contained, cryptographically verifiable container format for sensitive documents. A CI-TDF container carries its encrypted payload together with everything needed to authenticate, verify, and — for an authorized recipient — decrypt it: recipient key-access entries, integrity proofs at both the segment and whole-manifest level, and signed assertions about the payload's classification and origin. None of that depends on the network, storage location, or application that later opens the file.

It was built because the alternative was to wait: the documents that would authoritatively define a NATO/FVEY-aligned container format (ACP-240 Supplements 3 and 4) are not publicly available, and the labelling standards that are public (STANAG 4774/4778) define concepts and binding profiles but not a ready-made JSON container. Rather than defer ClassifyIt's need for a working, verifiable container until those gaps close, CI-TDF assembles a concrete format now — built on the open OpenTDF manifest structure, informed by STANAG 4774/4778's concepts, and precise enough that every cryptographic claim it makes can be independently checked (which this design process has actually done, repeatedly, against real signed containers).

That independence from any single verifier is also the point, practically, for zero trust: a CI-TDF container's confidentiality, integrity, and authenticity are all enforced by cryptography the recipient (or, for the digital-signature-bound assertions, even a non-recipient policy-enforcement point) can check directly — never by trusting the channel the file arrived through, the server that stored it, or an operator's say-so. It works fully offline today, with local certificate- and password-based key access; a federated kas mode is planned as an addition, not a replacement, so a deployment can start simple and grow into centralized or federated key management without discarding what it started with.

The current container is a ZIP package.

Entries:

  • 0.manifest.json — UTF-8 JSON manifest described here.
  • 0.payload — Compressed and encrypted payload bytes.
  • 0.metadata (optional) — Plaintext discovery/indexing metadata. See Metadata Fields. When absent from the container, the manifest's metadata object is also absent — the two are always in sync; there is no state where one exists without the other.

The reader also accepts legacy manifest.json, but new containers should write 0.manifest.json.

Relationship to Other Standards

This section covers what CI-TDF is not, and why — the positive rationale above should be read alongside it, not instead of it.

CI-TDF is a project-local container format. It is built on the public OpenTDF manifest structure (github.com/opentdf/spec) and informed by concepts from NATO STANAG 4774 (confidentiality metadata label syntax) and STANAG 4778 (metadata binding mechanism, specifically the digital-signature cryptographic artefact approach, per the publicly available ADatP-4778.2 companion document).

CI-TDF is not:

  • A conformant OpenTDF implementation. CI-TDF diverges from the published OpenTDF schema in several places — custom root-level fields (c14nAlg, rootSignature), a JWS-based assertion binding scheme distinct from OpenTDF's own Sign/Verify model, and a project-specific AAD construction. A tool built strictly against the OpenTDF schema should not be assumed to parse a CI-TDF manifest without modification.
  • A conformant implementation of ZTDF or ACP-240. ACP-240 Supplements 3 and 4 — the documents that define ZTDF's specific requirements — are CCEB/FVEY publications that are not publicly available and have not been reviewed as part of this design. No claim of ZTDF or ACP-240 conformance is made or implied.
  • A conformant implementation of the STANAG 4774/4778 binding profiles. STANAG 4778's own companion SRD (ADatP-4778.2) currently defines cryptographic artefact binding profiles for XML Signature and CMS only; it does not yet define a profile for JSON/JWS-based binding. CI-TDF's assertion binding is informed by STANAG 4778's choice of mechanism (digital signature over a shared-secret HMAC, for the reasons discussed in the design log) but is not a defined STANAG 4778 profile.

Where this document says "aligned with" or "informed by" a standard, it means the design intent follows that standard's concepts and rationale — not that the byte-level structure has been validated against that standard's official schema or test vectors.

Manifest Tree

[Root JSON Object]
|-- tdf_spec_version
|-- objectId
|-- createdAt
|-- c14nAlg
|-- rootSignature
|   |-- alg
|   |-- kid    (sender signature only)
|   |-- x5c    (sender signature only)
|   `-- sig
|-- payload
|   |-- source
|   |   |-- fileName
|   |   |-- mimeType
|   |   `-- fileSize
|   |-- compressionAlg
|   `-- hash
|       |-- alg
|       `-- value
|-- metadata                 (optional)
|   |-- compressionAlg
|   `-- hash
|       |-- alg
|       `-- value
|-- encryptionInformation
|   |-- method
|   |   |-- algorithm
|   |   |-- iv
|   |   |-- ivDerivation
|   |   |-- keyDerivation
|   |   `-- contentEncryptionKeyId
|   |-- keyAccess[]
|   |   |-- accessMode
|   |   |-- wrappingAlg
|   |   |-- p2s                 (password access only)
|   |   |-- p2c                 (password access only)
|   |   |-- url                 (certificate access)
|   |   |-- kid                 (certificate access)
|   |   |-- ephemeralPublicKey  (ECC certificate access only)
|   |   `-- wrappedKey
|   |-- policy               (planned, see Roadmap)
|   `-- segmentIntegrity
|       |-- aad
|       |   |-- profile
|       |   `-- fields[]
|       `-- segments[]
|           |-- tag
|           `-- encryptedSegmentSize
`-- assertions
    `-- iCLASS
        |-- org
        |-- class
        |-- rel[]      (optional)
        `-- cat[]      (optional)

assertions is a JSON object keyed by assertion name, not an array — currently one entry, iCLASS. No id field inside its content — the object key is the id. iCLASS is a bare object, protected only by rootSignature (whole-manifest coverage) — see the note below on why an iCLASS-level signature was considered and rejected. A data-provenance assertion previously existed here; it was removed — see Roadmap for when it would come back.

Supported Crypto

Content encryption:

  • AES-256-GCM — Payload encryption. The DEK is 32 bytes. Each encrypted segment stores ciphertext followed by a 16-byte GCM tag.
  • CI-TDF-segment-aad-v1 — Current AES-GCM AAD profile. The implementation authenticates a canonical JSON AAD object containing aadProfile, objectId, and payloadUrl.
  • HKDF-SHA256 — Used for internal derived keys.

Key access modes:

  • cert / RSA-OAEP-256 — Wraps the DEK for an RSA recipient certificate. Uses RSA-OAEP with SHA-256 and MGF1-SHA-256.
  • cert / ECDH-ES+A256KW — Wraps the DEK for an ECC recipient certificate. Uses ephemeral ECDH, a SHA-256-derived KEK, and AES-256 key wrap.
  • pass / PBES2-HS256+A256KW — Wraps the DEK from a user password. Uses PBKDF2-HMAC-SHA256 and AES-256 key wrap.

cert and pass are both local, offline key-access modes with no network dependency. A third mode, kas (remote Key Access Server, OpenTDF-style), is planned — see Roadmap. cert and pass will remain supported alongside kas once added; they are not an interim substitute for it.

Root Fields

tdf_spec_version

Purpose: The single, authoritative identifier of both the format family and its version. This is the standard OpenTDF field name (opentdf.io/spec/schema/opentdf/manifest uses it for a comparable purpose), repurposed here to carry a CI-TDF-specific, non-numeric-vs-OpenTDF value — deliberately not a value that could be misread as an OpenTDF release number. There is no separate profile/version field; this is the only one.

Type: string

Value domain: CI-TDF-<major>.<minor>

Current value: CI-TDF-1.0

Status: implemented

objectId

Purpose: Stable identifier for the Trusted Data Object. Not just descriptive — this exact string is embedded verbatim in the AAD JSON used to authenticate every AES-GCM segment, so its serialization must be fixed and mandatory, not merely "a UUID."

Type: string

Value domain: RFC 4122 UUID v4, canonical hyphenated form (8-4-4-4-12), uppercase hex, no enclosing braces, no urn:uuid: or other scheme prefix. Example: 1B016210-D709-4FDC-9DDB-E3578A7C1108.

Rule: this exact string — case included — is used byte-for-byte wherever objectId appears in AAD construction. No case normalization or reformatting is permitted between manifest write and AAD build.

Used by:

  • AES-GCM AAD

Status: implemented; value-domain rule above is a tightening of the previous "UUID string" definition

createdAt

Purpose: Container creation time. Kept deliberately as a simple root field rather than following OpenTDF's approach of bundling creation time into an optional, independently-signable system-metadata assertion (alongside SDK version, OS, architecture) — that independence isn't needed here, since createdAt is already covered by rootSignature's whole-manifest canonicalization, and a bare root field is simpler. Retained (rather than removed) because it may support a future use, e.g. an audit/debug assertion, without needing to be reconstructed later.

Type: string

Value domain: ISO 8601, UTC only, whole-second precision, always Z suffix — exactly YYYY-MM-DDTHH:MM:SSZ. No fractional seconds. No other timezone offsets (e.g. +00:00) permitted; Z is the only legal representation of UTC here.

Status: implemented. Informative only — not checked by any validation rule (e.g. no rejection for clock skew or future-dated containers).

c14nAlg

Purpose: Declares the single, global deterministic-serialization algorithm used to produce canonical bytes wherever this manifest needs something to hash or sign — rootSignature (whole manifest), aad (the small AAD object), and every assertion binding (that assertion alone). One declaration, implicitly consumed by all three; no per-scope restatement.

Named after the established XML-DSIG numeronym for "canonicalization" (C14N), matching the terminology used by STANAG 4778's own Digital Signature binding profile (XML Signature / CanonicalizationMethod).

A flat root string, not an object. Value domain: enum, currently one legal value: JCS-RFC8785. Declared as an enum (not a bare constant) so a future v2 canonicalization can be added without changing the field's meaning.

Status: implemented. Verified against an independent RFC 8785 canonicalizer for idempotency (re-canonicalizing this implementation's output reproduces it byte-for-byte) and against real signed manifests (ES256/ES512/RS512 rootSignature and assertion JWS signatures verified independently).

rootSignature

Purpose: Manifest-level integrity or digital signature, covering the entire canonicalized manifest, including assertions and payload.

Naming note: rootSignature is OpenTDF's own field name for this concept (their IntegrityInformation.RootSignature, and their tamper errors ErrRootSigValidation/ErrRootSignatureFailure), retained deliberately to signal real lineage. Scope differs, though: OpenTDF's rootSignature is an HMAC over the concatenation of segment hashes (ciphertext integrity only). This field's scope is broader — it covers the entire canonicalized JSON manifest, including assertions and payload. Same name, same conceptual role ("the one signature that covers everything"), larger scope — not byte-for-byte equivalent semantics.

This field answers a question independent of keyAccess[].accessMode: accessMode governs how each recipient unwraps the DEK (a confidentiality concern, decided per recipient). rootSignature.alg governs how the sender authenticates the whole manifest (an integrity/authenticity concern, decided once, depending only on whether the sender has a signing identity loaded) — the two are independent axes. A manifest can deliver keys via password only and still be asymmetrically signed, or deliver keys via certificate and still fall back to HS256.

Fields:

  • algHS256 — no persistent sender signing identity available. ES256, ES384, ES512, RS256, RS384, or RS512 — persistent sender signing identity available.
  • sig — Base64 HMAC or signature bytes.
  • kid — Sender certificate thumbprint (SHA-256 of the full DER x5c certificate). Present only in sender-signature mode. Kept even though it's derivable from x5c — gives a verifier a cheap string-comparison identity check (e.g. "does this signer match a keyAccess[].kid?") without decoding and hashing a certificate first.
  • x5c — Sender signing certificate — "X.509 Certificate Chain," the same header parameter name and purpose as JOSE/JWS's x5c (RFC 7515 §4.1.6). Currently stores a single certificate, not the full chain — see Future work. Present only in sender-signature mode.

HS256 vs. asymmetric mode — what each actually protects, and why there is no third option

The choice of alg here has a real, disclosed security trade-off — not just an implementation detail.

`HS256` (no persistent sender identity): keyed by DEK-derived material. This means only a recipient who can already unwrap the DEK can verify `rootSignature` — a policy-enforcement point or any party without decrypt access cannot verify manifest integrity at all in this mode. This is a real, accepted limitation for ad-hoc, password-only usage, not an oversight — it should be stated plainly in any deployment that might route containers through infrastructure needing to verify without decrypting, rather than silently relied upon.

Exact HS256 recipe:

  1. Build the root-signature input by parsing the manifest JSON, removing only rootSignature.sig, and canonicalizing the resulting JSON with the manifest's c14nAlg (JCS-RFC8785). The rootSignature object itself remains present, including alg.
  2. Derive the 32-byte root-signature HMAC key with HKDF-SHA256 using IKM = DEK, salt = baseIV, info = ASCII "-2", and L = 32.
  3. Compute HMAC-SHA256(rootSignatureKey, canonicalManifestBytes).
  4. Store the raw 32-byte HMAC as standard Base64 without embedded line breaks in rootSignature.sig.

Asymmetric mode (`ES*`/`RS*`) requires a *persistent* signing identity — CA-issued (preferred) or self-signed-but-reused across every container from that sender. A fresh, ephemeral self-signed certificate generated per container was considered and explicitly rejected: it produces a rootSignature.sig that validates cleanly, but with no way for a verifier to distinguish "signed by the legitimate sender's throwaway key" from "signed by an attacker's throwaway key" after tampering with the manifest and re-signing. That's worse than HS256's honest "cannot verify" — it's a false "verified," which looks like a security guarantee while providing none. A persistent identity, by contrast, supports trust-on-first-use (TOFU, the same model SSH host keys rely on): a verifier that has seen this sender's kid before can detect the moment it unexpectedly changes, even without a CA relationship. This does not protect the very first container a verifier ever sees from that sender (no design without an out-of-band identity check can), but it makes every subsequent container checkable and makes key substitution a visible signal rather than something silently accepted.

No ephemeral-per-container mode exists in this format — it was evaluated and rejected for the reason above, not merely left unimplemented.

Status: implemented and verified end-to-end for asymmetric mode. Both RS512/RSA-4096 and ES512/P-521 signed manifests have been independently canonicalized (using c14nAlg) and cryptographically verified against the embedded x5c certificate, confirming the canonicalization and signing pipeline agree byte-for-byte. HS256/DEK-derived mode confirmed implemented as described above; the PEP-unverifiable limitation is disclosed, not yet mitigated (mitigation would require persistent-identity signing to be mandatory rather than a fallback — a deployment-policy decision, not a schema change).

Future work:

  • make x5c a certificate-chain array
  • include local certificate validation metadata
  • record local CA validation outcome in the manifest
  • add a binding-creation timestamp distinct from createdAt, to support replay detection and to record that the signing key was valid at signing time even if since expired or revoked
  • consider whether persistent-identity signing should become mandatory (rather than an HS256 fallback) for deployments where PEP verification without decrypt access is a requirement

Payload Fields

payload.source

Purpose: Groups the facts that describe the original file, before any transform — as opposed to facts about the stored, compressed-and-encrypted blob, which live at payload's own level. This distinction is carried structurally (which object a field sits under), not by naming or a scope field.

Fields:

  • fileName — Purpose: Original plaintext filename. Metadata only — must not contain a local path. Mandatory rule: no path separators (/, \), no drive letters, no .. sequences. A filename that reaches an extraction routine unchecked is a path-traversal vector.
  • mimeType — Purpose: MIME type of the original plaintext content.
  • fileSize — Purpose: Original plaintext size, before compression and encryption. Type: integer.

Status: implemented

payload.compressionAlg

Purpose: Declares the compression applied to the original plaintext before encryption.

Value domain: enum. none (no compression) or zlib; additional algorithms may be added.

Current value: zlib

Status: implemented, none and zlib supported

payload.hash

Purpose: Hashes the stored, encrypted blob (0.payload's raw bytes, including GCM tags) — not the original file.

Fields:

  • alg — Value domain: enum. Current value: SHA-256.
  • value — Base64 digest of the raw encrypted 0.payload bytes.

Status: implemented

Encryption Information

encryptionInformation.method.algorithm

Purpose: Content encryption algorithm. The sole authoritative declaration of this fact in the manifest.

Value domain: enum. Current value: AES-256-GCM. Additional AEAD algorithms may be added.

Status: implemented

encryptionInformation.method.iv

Purpose: Base IV for segment IV derivation.

Type: Base64 12 bytes

Status: implemented

encryptionInformation.method.ivDerivation

Purpose: Describes segment IV derivation.

Current value: sha256-base-iv-plus-segment-counter

Exact derivation:

segmentIV = SHA-256(baseIV || int32_le(segmentIndex))[0..11]

where segmentIndex starts at 0 and is encoded as a 4-byte little-endian signed integer.

Status: implemented and verified — decryption using this exact derivation against real test containers succeeded.

encryptionInformation.method.keyDerivation

Purpose: Describes internal derived-key algorithm.

Current value: HKDF-SHA256

Status: implemented

encryptionInformation.method.contentEncryptionKeyId

Purpose: Non-secret content key identifier.

Current derivation: HMAC-SHA256(baseIV, DEK)

Status: implemented and verified — independently recomputed and matched against real test containers, confirming this is a reliable way to check a candidate DEK without attempting a full decrypt.

encryptionInformation.method.aadProfile

Purpose: Method-level declaration of AAD construction.

Suggested value: CI-TDF-segment-aad-v2 or profile-defined value.

Status: missing. Current AAD profile is declared under payload transforms and integrity information.

Key Access Fields

All current key-access entries have type = "wrapped".

The wrappedKey is never plaintext DEK.

keyAccess[].accessMode

Purpose: Selects the unwrap flow. This is the single source of truth for which access mode a keyAccess entry uses — no separate type/protocol fields exist, since both were always fully derivable from this one (cert/pass → local, wrapped delivery; kas → remote), never carrying independent information.

Current values:

  • cert
  • pass

Planned:

  • kas — remote Key Access Server, policy-gated key release. See Roadmap.

Status: cert and pass implemented for local certificate/password modes. kas planned.

keyAccess[].wrappingAlg

Purpose: Identifies the DEK wrapping algorithm.

Current values:

  • RSA-OAEP-256
  • ECDH-ES+A256KW
  • PBES2-HS256+A256KW

Status: implemented for local certificate/password modes

keyAccess[].url

Purpose: Locates the key source. What this means, and how authoritative it is, differs by accessMode — this field is not a single fixed protocol identifier, it takes on a different role per mode:

  • `cert` mode: urn:file:<certificate-filename> — the certificate's basename only, never a full local path. This is a hint for a human or a local file-picker, not a cryptographic check. The only authoritative identity check is kid matching the resolving certificate's SHA-256 thumbprint. A correct certificate that has been renamed or relocated must still be accepted on kid match alone — url should never cause a rejection by itself.
  • `pass` mode: not applicable — password mode has no external key source to locate; omitted.
  • `kas` mode (planned): url becomes genuinely authoritative — the actual KAS endpoint (e.g. an HTTPS URI) a client must contact to request the key. Unlike cert mode, this is not a hint; it is the address the unwrap protocol depends on.

Status: implemented for cert mode as a non-authoritative hint. kas mode planned, where the field's role changes to authoritative.

keyAccess[].kid

Purpose: Identifies the wrapping key.

Certificate mode: SHA-256 thumbprint of the full DER X.509 certificate.

Not valid meanings:

  • email address
  • common name
  • filename
  • public-key-only hash, unless the final profile explicitly changes this

Status: implemented for certificates

keyAccess[].ephemeralPublicKey

Purpose: Carries the ephemeral public key for ECC ECDH unwrap.

Required for: cert / ECDH-ES+A256KW

Omitted for:

  • RSA certificate mode
  • password mode

Status: implemented

keyAccess[].p2s

Purpose: PBKDF2 salt.

Required for: password mode

Type: Base64 16 bytes

Status: implemented

keyAccess[].p2c

Purpose: PBKDF2 iteration count.

Current value: 100000

Required for: password mode

Omitted for: certificate mode

Status: implemented, should become policy/configurable

keyAccess[].wrappedKey

Purpose: Recipient/password-specific encrypted DEK.

Type: Base64

Status: implemented

Not present: policyBinding. In the current cert/pass-only architecture it would be fully redundant with rootSignature, which already covers the entire manifest (every wrappedKey and every assertion together) in one signature — any tampering breaks rootSignature.sig regardless. policyBinding becomes necessary once kas mode exists: a KAS evaluates policy and rewraps a key dynamically, after the original signing event and by a different party than the sender — something rootSignature structurally cannot vouch for, since it was computed once, at creation. See Roadmap.

Segment Integrity

Purpose of this whole object: encryption-side integrity only — segment tags, segment sizing, and AAD construction. Scoped to encryptionInformation because none of it means anything outside the context of how the payload was AES-GCM-encrypted.

encryptionInformation.segmentIntegrity.aad

Purpose: Describes AES-GCM AAD.

Current fields:

  • profile — Current value: CI-TDF-segment-aad-v1
  • fields — Current value: ["aadProfile", "objectId", "payloadUrl"]

AAD canonicalization uses the manifest's single global c14nAlg declaration; no separate canonicalization field exists at this scope.

Exact AAD object before canonicalization (per root c14nAlg):

{"aadProfile":"CI-TDF-segment-aad-v1","objectId":"<objectId>","payloadUrl":"0.payload"}

Status: implemented and verified — fields now matches the actual AAD construction exactly; decryption using this exact construction against real test containers succeeded.

Planned AAD fields (v2):

  • segment.index
  • encryptedSegmentSize
  • policyDigest
  • contentEncryptionKeyId

encryptionInformation.segmentIntegrity.segments[]

Purpose: Per-segment integrity metadata.

Current fields:

  • tag — AES-GCM authentication tag for this segment.
  • encryptedSegmentSize — Ciphertext length for this segment, including the tag. The pre-encryption plaintext/compressed length is not separately stated — AES-GCM never pads, so it is always exactly encryptedSegmentSize minus the tag length for the algorithm named in encryptionInformation.method.algorithm (16 bytes for AES-256-GCM). A reader deriving this value needs an algorithm-to-tag-length mapping; the two other AEAD algorithms currently anticipated (ChaCha20-Poly1305, AES-256-GCM-SIV) also use 16-byte tags.

Status: implemented and verified — segment tag matches the trailing 16 bytes of 0.payload exactly on real test containers.

Future fields:

  • index — Explicit segment index.
  • hash — Optional object {alg, value} for a SHA hash of the segment's ciphertext, separate from the tag field.

Assertions

The implementation currently writes one assertion type: iCLASS.

A data-provenance assertion (an originator field, self-asserted) previously existed. It was removed: originator never carried more reliable information than what rootSignature.x5c/kid already prove cryptographically about who signed the manifest, and the field had sat hardcoded, never reflecting a real value. Reintroduce specifically when signer identity and content-originator identity can genuinely differ — e.g. an automated pipeline signing on behalf of a human author, or a system relaying a document originated by someone else — not before. See Roadmap.

iCLASS Assertion

Purpose: A compact, human-and-machine-readable classification statement — this project's own iCLASS design (see classifyit.eu case study). Replaces the earlier STANAG-4774-shaped classification assertion entirely.

Keyed as iCLASS in the assertions object. A bare object — no id, type, scope, appliesToState, statement, or binding wrapper, and no signature of its own — protected only by rootSignature (whole-manifest coverage).

An iCLASS-level signature (objectId + a freshly-computed payloadHash + org/class/rel/cat, signed and stored as sig) was designed and then deliberately rejected, not simply never considered. It closed a real splicing risk (lifting a validly-signed iCLASS block from one container into another with a different, undisclosed classification) — but the version secure enough to actually close that risk requires recomputing payloadHash from the real 0.payload bytes on every check, which for a large file costs more than rootSignature verification itself (JSON canonicalization vs. hashing the entire ciphertext). That defeats the original motivation — a cheap check for infrastructure that can't afford full manifest verification — and since manifest and payload are always bundled in the same container, there's no deployment shape where iCLASS.sig would be reachable without rootSignature also being reachable and cheaper. Revisit only if a real deployment shape emerges where an iCLASS-only artifact is separated from its container.

org

Purpose: Originating organization/nation short code. The current generator exposes this as the editable iCLASS originator field.

Example: BEL

Status: implemented

class

Purpose: Classification level, iCLASS single-letter code.

Value domain: P / O / R / C / S / T

Status: implemented. Mandatory — an assertion with no classification level says nothing.

rel

Purpose: Releasability codes.

Type: array of short codes. Optional.

Status: implemented

cat

Purpose: Category/compartment codes describing the type of data in the payload.

Type: array of short codes, flat (no restrictive/permissive typing). Optional.

Status: implemented

Assertion Binding (currently unused, kept as documented, reusable infrastructure)

iCLASS does not use this mechanism — it is a bare object, protected only by rootSignature. This section's mechanism is real, built, and independently verified (JWS signatures reconstructed and cryptographically checked against the embedded x5c certificate on real test containers, ES256 and ES512) — documented here so it isn't rediscovered from scratch if a future assertion needs independent verifiability (e.g. a reintroduced data-provenance, per the condition above, or Core Metadata).

binding.method

Values:

  • none — No sender identity was loaded. Assertion is root-integrity protected but not individually signed.
  • jws — Sender identity was loaded. Assertion has a compact JWS-like signature.

Known inefficiency, if reintroduced: when an assertion signer is the same identity as rootSignature (the common case), the JWS header re-embeds the full x5c certificate chain a second (or third) time, purely redundantly — same kid, same certificate bytes. Resolve kid against rootSignature.x5c or a manifest-level certificate table instead of re-embedding x5c, while keeping kid itself (cheap, and needed if an assertion is ever signed by a different identity than the root).

binding.profile

Value when signed: ADatP-4778 (informed by; not a conformance claim)

binding.signature

Purpose: Compact JWS-like signature.

binding.digest

Purpose: Digest of the bound statement/reference in addition to signature. Never implemented.

binding.signer

Purpose: Assertion-level signer metadata (kid/x5c). Never implemented as a separate field — was embedded directly in the JWS header instead; see the redundancy note above.

Core Metadata (planned)

Purpose: Provide discovery and lifecycle metadata for the trusted data object, informed by STANAG 5636 concepts.

Expected assertion ID: ci-tdf-core-metadata

Expected fields may include:

  • title
  • description
  • subject[]
  • creator
  • publisher
  • contributor[]
  • date
  • type
  • format
  • identifier
  • source
  • language
  • relation[]
  • coverage
  • rights
  • lifecycle or operational-currency metadata

Status: missing

Metadata Fields

Purpose: 0.metadata is a plaintext (never encrypted) entry in the ZIP container, holding information intended to support discovery and indexing — enough to identify or locate content, deliberately not enough to convey sensitive content or full context. Because it is unencrypted, it is verifiable by any party from hash and rootSignature alone, without a DEK or recipient credentials — e.g. a search or indexing system can confirm a 0.metadata entry hasn't been tampered with, without ever gaining decrypt access. The current generator stores the selected metadata file byte-for-byte as clear text in 0.metadata; see metadata.compressionAlg.

Exactly one metadata entry, 0.metadata, is currently supported, and it is optional — a container may validly contain only 0.manifest.json and 0.payload. When a metadata file is selected in the generator, the manifest's metadata object is emitted and the 0.metadata ZIP entry is added. See Roadmap for multiple metadata entries.

Open question, not yet resolved: the planned "Core Metadata" assertion (STANAG-5636-informed discovery/lifecycle fields — title, creator, subject, etc.) and this 0.metadata file serve overlapping purposes — both are about findability rather than content. Worth deciding whether that discovery content lives inside 0.metadata itself, as an inline assertion, or both, rather than building two separate discovery mechanisms.

metadata.compressionAlg

Purpose: Declares the compression applied to the metadata content.

Value domain: enum. none or zlib; additional algorithms may be added.

Current value: none

Status: implemented for cleartext none; zlib is reserved for future use

metadata.hash

Purpose: Hashes the stored metadata blob's plaintext bytes directly — no encryption to account for, unlike payload.hash.

Fields:

  • alg — Value domain: enum, same domain as payload.hash.alg. Current value: SHA-256.
  • value — Base64 digest of the raw 0.metadata bytes.

Status: implemented

Validation Rules

A reader should reject a container if any of these checks fail:

  1. The selected keyAccess entry cannot unwrap a 32-byte DEK.
  2. rootSignature.sig does not validate over canonical manifest JSON with rootSignature.sig removed.
  3. Any segment GCM tag in segments[].tag does not match the tag appended to 0.payload.
  4. AES-GCM decrypt fails using the derived segment IV, DEK, and AAD.
  5. Payload decompression fails after successful decrypt.
  6. The decompressed payload's length does not equal payload.source.fileSize.
  7. The manifest's metadata object and the container's 0.metadata ZIP entry disagree on presence — one exists without the other.
  8. When 0.metadata is present, its declared metadata.hash does not match the stored 0.metadata bytes.

These validation rules have been exercised against real test containers during development; rules 2 and 4 were independently re-verified from outside the Delphi implementation (Python, using an independent RFC 8785 canonicalizer and standard ECDSA/RSA verification), not just self-tested. Rule 6 reflects a check already relied on in that verification (decompressed length matched the manifest's declared size exactly) that had not previously been written down as a formal rule.

An additional dormant rule — any binding.method = "jws" assertion failing payload or signature verification — applied when assertions carried independent bindings (see Assertion Binding). No current assertion does; the rule is dormant, not deleted, since it would apply again immediately if a future assertion reintroduces binding.

Roadmap

  • Multiple payload and metadata entries per container — e.g. 0.payload, 1.payload, 2.payload alongside 0.metadata, 1.metadata, under a single container. Under consideration, not yet designed in full. Key open decisions already identified: a single shared DEK across all payload entries in a container (current direction — simpler, and implies one classification decision covers the whole container, since anything decryptable by one recipient is decryptable for all payload entries under a shared DEK) versus per-payload DEKs (needed only if differently-classified payloads must coexist in one container, which is a materially larger change touching keyAccess[] and assertion scope); payload/metadata becoming arrays with a stable per-entry id independent of filename position; and whether an explicit relationship needs to be recorded between a given metadata entry and the payload entry it describes.
  • kas access mode — remote Key Access Server, policy-gated key release, alongside the existing cert/pass local modes (not a replacement for them). Requires reintroducing keyAccess[].policyBinding (removed for now — redundant with rootSignature in the cert/pass-only architecture), since a KAS rewraps keys dynamically, after the original signing event and by a different party than the sender, which rootSignature cannot vouch for.
  • encryptionInformation.policy — machine-readable ABAC-style access-control policy object, most likely required once kas mode exists.
  • encryptionInformation.policy.binding — standalone cryptographic binding for the policy object.
  • Richer AAD fields (v2): segment index, segment size, encrypted segment size, policy digest, content key ID.
  • Explicit segment metadata: index, optional ciphertext SHA hash object separate from tag.
  • Sender certificate chain: x5c as an array of certificate-chain entries rather than one string.
  • Trim redundant per-assertion x5c embedding (see Assertion Binding note above).
  • iCLASS.cat is currently a flat array with no typing (no restrictive/permissive distinction). Revisit only if flat proves insufficient for real access-control logic — the typed tagName/type/values structure originally designed for the STANAG-shaped classification assertion was dropped along with it, not carried into iCLASS.
  • Reintroduce a data-provenance assertion specifically when signer identity and content-originator identity can genuinely differ (e.g. an automated pipeline signing on behalf of a human author, or a relay handling a document originated elsewhere) — not before.
  • Trust validation metadata: local issuer CA, validation time, and EKU decisions recorded in the manifest.
  • Reintroduce assertion binding (JWS mechanism, documented and verified but currently unused) if any future assertion needs independent verifiability apart from rootSignature — see Assertion Binding.
  • Full STANAG-4774-informed fields (ownerProducer, disseminationControls, displayMarking, policyReference) — dropped along with the classification assertion in favor of iCLASS; revisit only if iCLASS's minimal shape proves insufficient.
  • Core metadata assertion: discovery, lifecycle, creator, publisher, identifier, and related fields.
  • Comprehensive metadata reference: optional 0.metadata entry with hash and binding.
  • Full provenance: authority, timestamp, tool, action.
  • Object-level schema metadata: schema, schemaVersion, issuer, creator, or authority.
  • Binding-creation timestamp on rootSignature and each assertion binding, distinct from createdAt.
  • Decide whether HS256/no-sender-identity mode should be disallowed for containers that may need verification without decrypt access.

Processing rules still needed:

  • Certificate-chain serialization in x5c.
  • EKU checks.
  • Policy evaluation against authenticated user/entity attributes.
  • Controlled-vocabulary normalization for labels and releasability.
  • Audit evidence for validation and unwrap decisions.

Implementation Limits

  • This document describes the current Delphi implementation and profile direction for CI-TDF.
  • It is not a formal OpenTDF, ZTDF, ACP-240, or NATO STANAG conformance statement — see "Relationship to Other Standards" above.
  • Local CA signature validation, certificate validity-date checks, and key-usage checks are implemented for local certificate files.
  • Multi-hop chain construction and manifest serialization of the certificate chain are not yet implemented.
  • x5c currently stores one Base64 DER certificate string, not a JSON array of a chain, and is currently duplicated across rootSignature and each signed assertion even when the signer identity is the same.
  • AAD currently covers aadProfile, objectId, and payloadUrl.
  • Core metadata (STANAG-5636-informed) is not yet implemented.
  • Single optional cleartext 0.metadata entries are emitted byte-for-byte and hash-validated. Richer comprehensive metadata references are not yet implemented.
  • Only cert and pass key-access modes exist; kas is planned, not a design dead-end.
56075 Koblenz, Germany This email address is being protected from spambots. You need JavaScript enabled to view it.
No module Published on Offcanvas position