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_001TA-5. OfflineKit does not yet produce this artifact; building the producer is OfflineKit’s own work and is tracked inSPRINT_20260722_025. This document is the specification that side inherits — it is not a description of something that already runs.
- Contract project (producer-owned, closed):
src/OfflineKit/__Libraries/StellaOps.OfflineKit.TrustBundle.Contracts - Consumer:
src/Evidence/__Libraries/StellaOps.Evidence.TimestampAssurance.TrustBundle - Consumer design: Timestamp Assurance design §3.2, §4.2
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:
- The DSSE payload is the exact bytes of
manifest.jsonas 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. - 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.
- 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
}
]
}
| Field | Rule |
|---|---|
schemaVersion | Exactly timestamp-trust-bundle/v1. A v1 consumer refuses anything else rather than attempting an upgrade. |
bundleId | Operator-safe identifier. Appears in health output and audit records, so never a URL, a path, or anything credential-bearing. |
producer | Who 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. |
createdAt | When the bundle was assembled. Says nothing about the age of the material inside it. |
validUntil | The producer’s declared expiry for the artifact. A consumer may apply a shorter policy of its own; it may never apply a longer one. |
parts | At least one. Digests must be unique across the array. |
Part fields
| Field | Rule |
|---|---|
domain | One of §5. The set is CLOSED in v1. |
mediaType | One the table in §5 permits for that domain. |
digest | sha256: + exactly 64 lowercase hex. Case is part of the contract, not a style preference — see §7. |
size | Blob 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. |
generation | Monotonic 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
| Domain | Media type | Content |
|---|---|---|
timestamp.trust-list | application/vnd.etsi.tsl+xml | A signed trusted-list pack. |
timestamp.trust-anchors | application/pkix-cert | One DER X.509 certificate per part. |
timestamp.revocation-ocsp | application/ocsp-response | A DER RFC 6960 OCSP response captured while the estate still had egress. |
timestamp.revocation-crl | application/pkix-crl | A DER X.509 CRL, likewise. |
timestamp.time-proof | application/timestamp-token | A 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>" }]
}
payloadmust decode tomanifest.jsonbyte-for-byte. A consumer that skips this check accepts the classic detached-signature substitution: the envelope verifies perfectly and covers a document nobody is going to act on.keyidis a HINT, never an authorisation. A consumer tries every configured anchor regardless of it — honouring it as a filter would let a producer suppress verification simply by renaming a key.- ECDSA signatures are IEEE P1363 (fixed-field
r‖s), not DER. This cuts against the wider DSSE ecosystem, which usually writes ES256 as a DERSEQUENCE, so it is stated rather than assumed: the estate’s own signer isStellaOps.Cryptography.EcdsaSigner, whoseECDsa.SignData(data, hash)call emits P1363, andDefaultAsymmetricSignatureVerifierreadsIeeeP1363FixedFieldConcatenationto match. Accepting both encodings would make the signature malleable at the encoding layer, so exactly one is accepted. - Curves and hashes pair as JOSE does: P-256/SHA-256, P-384/SHA-384, P-521/SHA-512. Any other key type is refused as
trust-bundle-signature-algorithm-unsupported— never accepted, never ignored.
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.
- Read
manifest.jsonandmanifest.dsse.json, each under a byte cap. - Parse the envelope only. Nothing from the manifest is read or believed yet.
- Confirm the envelope payload is byte-identical to the manifest file.
- Verify the signature.
- Only now parse the manifest — and only now may its
bundleIdandproducerbe disclosed in a rejection. - Apply schema-version, producer-allowlist and validity-window policy.
- 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:
- write
manifest.jsononce and sign those exact bytes; - write each part at
blobs/sha256/<hex>where<hex>is that blob’s own digest; - declare
sizeequal to the blob’s real length; - keep
generationmonotonic per domain across republishes; - set
validUntilto a window it is willing to stand behind, and republish before it lapses; - ship no private key material in the bundle — the artifact carries public trust material and captured public responses only.
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
- Trusted lists are transported but not interpreted. Reading an ETSI trusted list means verifying its signature and parsing its issue/next-update semantics, which lives entirely in
StellaOps.Cryptography.Plugin.Eidas— and D-TA-11 measured that the Cryptography plugin ABI exposes no qualified-status or trust-list contract to reach it through. So a bundle may carry atimestamp.trust-listpart andeidas.trust-list-freshstill reportssource-not-configured. Transporting the material and interpreting it are separate problems; v1 solves the first. - The time proof has no consumer yet. A static signed token is a LOWER BOUND on the current instant, not a measurement of it:
now - genTimereturns the age of the bundle and would report a correctly-set clock as days fast. The right consumer is a monotonic-rollback check, which does not exist. The domain stays in the contract because that check will need this material. - There is no revocation-of-the-bundle mechanism. Withdrawal is by republishing with a shorter
validUntiland by the installation’s ownMaximumAge.
11. Consumer configuration
Evidence:TimestampAssurance:TrustBundle — see StellaOps.Evidence.TimestampAssurance.TrustBundle.TrustBundleOptions. Every defect throws at bind time.
| Key | Meaning |
|---|---|
BundleDirectory | Local mount point. A path, never a URL — there is no URL form here or in the contract. |
Anchors[].KeyId / Anchors[].PublicKeyPemFilePath | Accepted 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. |
AcceptedProducers | Optional second gate beyond the signature. Empty means the signature is the only authority. |
MaximumPartBytes / MaximumTotalBytes / MaximumDocumentBytes | Read caps, applied before the bytes are read. |
MaximumAge | Shortens the producer’s window. It can never lengthen it. |
FutureDatingTolerance | Absorbs ordinary clock disagreement between the kit builder and the sealed estate; not a rollback allowance. |
RequiredRevocationKinds | ocsp, 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. |
