ClassifyIt 7.0 Classification meets CI-TDF document exchange. Discover what’s new

Preserved technical reference

CI-TDF manifest reference

The existing format discussion, retained at its original URL.

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:

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:

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:

Key access modes:

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:

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:

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:

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:

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:

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:

Planned:

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

keyAccess[].wrappingAlg

Purpose: Identifies the DEK wrapping algorithm.

Current values:

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:

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:

Status: implemented for certificates

keyAccess[].ephemeralPublicKey

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

Required for: cert / ECDH-ES+A256KW

Omitted for:

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:

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):

encryptionInformation.segmentIntegrity.segments[]

Purpose: Per-segment integrity metadata.

Current fields:

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

Future fields:

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:

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:

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:

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

Processing rules still needed:

Implementation Limits