timestamp-trust-bundle/v1 — offline trust material for Timestamp Assurance

Status: SPECIFIED and CONSUMER-IMPLEMENTED (2026-08-22). The producer is not built yet. The wire contract, the consumer reader/validator and the sealed-mode source adapter landed with SPRINT_20260819_001 TA-5. OfflineKit does not yet produce this artifact; building the producer is OfflineKit’s own work and is tracked in SPRINT_20260722_025. This document is the specification that side inherits — it is not a description of something that already runs.

1. Why the artifact exists

A sealed estate cannot reach a trusted list, an OCSP responder, or a CRL distribution point, but its Timestamp Assurance checks still have to say something true about certificate trust and revocation freshness. OfflineKit is the family that moves material across the seal; Evidence is the family that decides what the material means. This artifact is the seam between them.

Under CoC §8.3 and ADR-039 P19 the only legal source seam between two independently deployable services is a producer-owned closed contract project. That is what StellaOps.OfflineKit.TrustBundle.Contracts is: zero ProjectReference, zero PackageReference, BCL only, asserted by a test that reads the loaded assembly’s own GetReferencedAssemblies(). A consumer that references it cannot thereby compile any OfflineKit host, persistence, migration or worker, because there is none in the graph to reach.

2. The load-bearing decision: identity and integrity only

The manifest declares WHICH bytes, never WHAT THEY MEAN. There is no notAfter, no nextUpdate, no status, no subject and no verified field anywhere in it, and there never will be in v1. The consumer parses the actual DER or XML and derives every semantic fact itself.

This is not minimalism. The whole SPRINT_20260819_001 programme exists because the predecessor subsystem decided certificate expiry from a configured ExpiresAt, reachability from an HTTP HEAD, and token validity from a provider that was never available. A manifest field saying notAfter would reintroduce exactly that defect one layer further out — and this time it would be inside a signature, so it would look authoritative. A signed lie is worse than an unsigned one.

The rule is pinned by test (No_part_field_asserts_a_semantic_fact_about_the_material, one case per forbidden field name), not left to review.

Corollary — what the manifest legitimately does declare: createdAt and validUntil are properties of the ARTIFACT, not of the certificates inside it, and producer is provenance. Those are the producer’s own facts about its own package, and the consumer has no way to derive them.

3. Layout

<bundle-root>/
  manifest.json          # the DSSE payload, byte-for-byte
  manifest.dsse.json     # detached DSSE envelope over manifest.json
  blobs/
    sha256/
      <64 lowercase hex> # one file per part, addressed by its own digest

Three rules, each chosen so that a defect is structural rather than a matter of care:

  1. The DSSE payload is the exact bytes of manifest.json as written. Nothing re-serializes it before verification, so producer and consumer cannot disagree about a canonical form — the commonest way a signed-manifest scheme fails in practice.
  2. Every part is addressed by its own digest. A part therefore has no producer-chosen file name for a consumer to trust, and path traversal is not expressible: the consumer derives the path from the digest in the signed manifest rather than reading one out of it.
  3. The manifest and its signature are separate files. An unsigned bundle is a MISSING FILE, not an empty field a lenient reader might skip past.

4. Manifest

{
  "schemaVersion": "timestamp-trust-bundle/v1",
  "bundleId": "kit-2026-08",
  "producer": "stellaops-offline-kit",
  "createdAt": "2026-08-01T00:00:00+00:00",
  "validUntil": "2026-09-01T00:00:00+00:00",
  "parts": [
    {
      "domain": "timestamp.trust-anchors",
      "mediaType": "application/pkix-cert",
      "digest": "sha256:<64 lowercase hex>",
      "size": 1187,
      "generation": 1
    }
  ]
}
FieldRule
schemaVersionExactly timestamp-trust-bundle/v1. A v1 consumer refuses anything else rather than attempting an upgrade.
bundleIdOperator-safe identifier. Appears in health output and audit records, so never a URL, a path, or anything credential-bearing.
producerWho built it (DC-18 gap 1). Import policy differentiates a first-party kit build from a partner mirror on this field, so it is inside the signature.
createdAtWhen the bundle was assembled. Says nothing about the age of the material inside it.
validUntilThe producer’s declared expiry for the artifact. A consumer may apply a shorter policy of its own; it may never apply a longer one.
partsAt least one. Digests must be unique across the array.

Part fields

FieldRule
domainOne of §5. The set is CLOSED in v1.
mediaTypeOne the table in §5 permits for that domain.
digestsha256: + exactly 64 lowercase hex. Case is part of the contract, not a style preference — see §7.
sizeBlob length in bytes. Declared as well as implied so a consumer can refuse an oversized part BEFORE reading it; a digest can only be checked once you have already read everything.
generationMonotonic per-domain regeneration counter (DC-18 gap 3), for an n/n-1 reader window across a rolling republish.

5. Part domains and media types

DomainMedia typeContent
timestamp.trust-listapplication/vnd.etsi.tsl+xmlA signed trusted-list pack.
timestamp.trust-anchorsapplication/pkix-certOne DER X.509 certificate per part.
timestamp.revocation-ocspapplication/ocsp-responseA DER RFC 6960 OCSP response captured while the estate still had egress.
timestamp.revocation-crlapplication/pkix-crlA DER X.509 CRL, likewise.
timestamp.time-proofapplication/timestamp-tokenA DER RFC 3161 token used as a proof of time. RFC 3161 rather than an invented format, because a sealed estate already has to verify RFC 3161 tokens and a second time format would mean a second verification story.

The vocabulary is closed, and a reader meeting an unknown domain fails the whole bundle rather than ignoring the part. From inside, “a part I do not understand” and “a part I was not meant to need” are indistinguishable, and only one of them is safe to skip. New domains arrive with a new schema version, which a v1 consumer already refuses.

Dispatch is by media type, not by domain (DC-18 gap 4): a domain says what a part is FOR, a media type says how to decode it, and one domain may gain a second encoding without a new domain name.

6. Signature

manifest.dsse.json is a DSSE envelope (secure-systems-lab/dsse, protocol.md):

{
  "payload": "<base64 of manifest.json>",
  "payloadType": "application/vnd.stellaops.timestamp-trust-bundle.manifest+json;version=1",
  "signatures": [{ "keyid": "kit-2026", "sig": "<base64>" }]
}

7. Digest spelling

sha256: followed by exactly 64 lowercase hex characters.

Case is part of the contract because a digest is used as a dictionary key, as a filesystem path segment, and as an equality test, all at once. If sha256:AB… and sha256:ab… were both legal, two manifests naming the same bytes would compare unequal in one place and equal in another. Rejecting uppercase at the boundary makes that unrepresentable rather than merely unlikely.

8. Consumer validation order

The order is the security property, and steps 3–5 are the ones that get inverted in practice.

  1. Read manifest.json and manifest.dsse.json, each under a byte cap.
  2. Parse the envelope only. Nothing from the manifest is read or believed yet.
  3. Confirm the envelope payload is byte-identical to the manifest file.
  4. Verify the signature.
  5. Only now parse the manifest — and only now may its bundleId and producer be disclosed in a rejection.
  6. Apply schema-version, producer-allowlist and validity-window policy.
  7. For each part: vocabulary, declared size, actual size, then digest — before its bytes go anywhere.

Parsing the manifest first is convenient (you want the bundleId for the log line) and it means the reader has acted on attacker-controlled fields before anything was verified. Until step 4 the manifest is a byte array.

A rejection may disclose only safe provenance. The reason code, the part digest and part domain, and — only after the signature verified — the bundleId and producer. Never a filesystem path, a parser message, or material from inside a part. Before verification, bundleId and producer are attacker-controlled strings, and echoing one into an operator’s health output turns a rejected artifact into a message-injection surface.

Reason codes are stable and enumerated in StellaOps.Evidence.TimestampAssurance.TrustBundle.TrustBundleRejectionReasons.

9. Producer obligations

A conforming producer must:

A producer must not add a field that states a fact about the material (§2), and must not introduce a URL field of any kind: the contract names no destination anywhere, which is what makes sealed no-egress structural rather than a rule someone has to keep.

10. What v1 deliberately leaves unsolved

11. Consumer configuration

Evidence:TimestampAssurance:TrustBundle — see StellaOps.Evidence.TimestampAssurance.TrustBundle.TrustBundleOptions. Every defect throws at bind time.

KeyMeaning
BundleDirectoryLocal mount point. A path, never a URL — there is no URL form here or in the contract.
Anchors[].KeyId / Anchors[].PublicKeyPemFilePathAccepted signing keys, mounted as PEM public keys. There is deliberately no inline field to fall back to (AGENTS.md §2.12), and an unresolvable path throws rather than continuing with fewer anchors.
AcceptedProducersOptional second gate beyond the signature. Empty means the signature is the only authority.
MaximumPartBytes / MaximumTotalBytes / MaximumDocumentBytesRead caps, applied before the bytes are read.
MaximumAgeShortens the producer’s window. It can never lengthen it.
FutureDatingToleranceAbsorbs ordinary clock disagreement between the kit builder and the sealed estate; not a rollback allowance.
RequiredRevocationKindsocsp, crl, or both. Required, with no default: an empty cache trivially contains no stale entry, so a freshness check over a cache holding nothing passes unless the installation independently states what should have been there.