Product Update Manifest Contract v1
Contract: stella.ops/productUpdateManifest@v1 (constant ProductUpdateManifestContract.SchemaVersion). Predicate type: stella.ops/product-update-manifest@v1 (PredicateTypes.StellaOpsProductUpdateManifest; also returned from PredicateTypes.GetAllowedPredicateTypes()). Manifest media type: application/vnd.stellaops.product-update-manifest.v1+json (ProductUpdateManifestContract.ManifestPayloadType). DSSE in-toto media type: application/vnd.in-toto+json (ProductUpdateManifestContract.InTotoPayloadType). This is the payloadType the DSSE envelope carries. Status: Active Signer contract; release CI publication remains blocked until the signed SBOM archive dependency and release workflow wiring are complete. Sprint: SPRINT_20260430_004 Owner: Attestor/Signer guild + Stella manufacturer of record Implementation: src/Attestor/StellaOps.Signer/StellaOps.Signer.Core/ProductUpdateManifest.cs (ProductUpdateManifestContract, the ProductUpdateManifest record graph, ProductUpdateManifestBuilder, and ProductUpdateManifestOfflineVerifier). Predicate-type constants live in PredicateTypes.cs. The EU signing-registry entry is defined in EuSigningContracts.cs (EuSigningPayloadRegistry.ProductUpdateManifest). Schema-pinning policy: Pinned per Stella minor release. Consumers MUST require the exact schemaVersion they support and MUST NOT auto-follow newer manifest schemas. Deterministic fixture location: src/Attestor/StellaOps.Signer/StellaOps.Signer.Tests/Signing/ProductUpdateManifestTests.cs. Replay/offline expectations: Manifest canonicalization, DSSE signature verification, and semantic subject-to-predicate checks MUST run without network access. EU signing registry: product-update-manifest-v1 in docs/contracts/eu-signing-payload-registry-v1.md (registry id EuSigningPayloadRegistry.ProductUpdateManifest; registered with PayloadShape = InTotoStatement, Audience = ProductManufacturer, ExpectedSignerProfile = stella-manufacturer-release, schema-pin JSON path schemaVersion).
Purpose
product-update-manifest-v1 is the signed release manifest for Stella Ops binaries and container images, and the authoritative input for CRA Annex I update-channel integrity. It is the contract for the Signer pipeline that produces it, the Router gateway that publishes its metadata, and the stella verify release-manifest CLI that verifies it offline.
The manifest binds:
- Stella release identity and version.
- CRA placement metadata from
docs/legal/cra-product-placement-anchor.md. - Support lifecycle values from
docs/contracts/product-lifecycle-v1.md. - OCI image digests and SBOM references.
- Release notes and fixed-advisory summaries.
The manifest is signed as an in-toto statement through the existing Signer pipeline. The manifest body does not contain its own mutable signature field; the signature is the surrounding DSSE envelope.
Canonicalization And Signing
ProductUpdateManifestBuilder.Normalize performs the deterministic normalization (it also runs Validate first and throws SignerValidationException on any failure):
- Trim
release,version,craApplicability,releaseNotesUrl, and the string fields of each image and advisory. - Normalize image entries: trim
name/platform/sbomRef, lowercase the digest, then order byname, thenplatform, thendigestusing ordinal ordering. - Normalize fixed advisories: order by
source, thenidusing ordinal ordering. Within each advisory,cveIdsare trimmed, upper-cased, empty entries dropped, de-duplicated, and ordinal-sorted. - Normalize SHA-256 digests to lowercase
sha256:<64 hex>(NormalizeSha256Digest). - Canonicalize the normalized manifest JSON with the repo canonical JSON helper (
CanonJson.Canonicalize) viaProductUpdateManifestBuilder.SerializeCanonical. - Compute
manifestSha256 = CanonJson.Sha256Prefixed(canonicalManifestBytes)viaProductUpdateManifestBuilder.ComputeSha256(returns thesha256:<hex>form). ProductUpdateManifestBuilder.BuildSigningRequestbuilds the in-toto subjects.SignerStatementBuilder.BuildStatementPayloadthen emits the statement with:_type:https://in-toto.io/Statement/v0.1(the Signer default emitted byBuildStatementPayload(request);GetRecommendedStatementTyperecommendshttps://in-toto.io/Statement/v1for StellaOps predicate types, but the product-update-manifest path uses the default).predicateType:stella.ops/product-update-manifest@v1.predicate: canonical manifest JSON.subject[0]:stella-release-manifest:<release>:<version>with digest{ "sha256": <manifestSha256 hex without prefix> }.- Additional subjects for each image, using the image
nameand the imagesha256digest hex.
- Sign the statement as a DSSE envelope with
payloadType = application/vnd.in-toto+json.
BuildSigningRequest also requires a scannerImageDigest, a ProofOfEntitlement, and SigningOptions — these flow through the standard Signer pipeline and are not part of the manifest body.
Re-signing with the same manifest inputs, signing key, and Signer options MUST produce the same statement payload hash. Signature bytes may vary by provider when the configured algorithm is randomized, but the manifest hash and statement payload hash must remain stable.
JSON Shape
{
"schemaVersion": "stella.ops/productUpdateManifest@v1",
"release": "stella-ops",
"version": "2026.9.0",
"placedOnMarketAt": null,
"madeAvailableInEu": "2026-09-01",
"craApplicability": "pre-cra",
"substantialModificationOf": null,
"supportPolicy": {
"lifecycleSchema": "stella.ops/productLifecycle@v1",
"releaseTrain": {
"version": "2026.9",
"releasedAt": "2026-09-01",
"securitySupportUntil": "2031-09-01",
"eolAt": "2031-09-01",
"lts": false
}
},
"images": [
{
"name": "registry.stella-ops.org/stellaops/signer",
"platform": "linux/amd64",
"digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"sbomRef": "oci://registry.stella-ops.org/stellaops/signer:2026.9.0-sbom"
}
],
"releaseNotesUrl": "https://stella-ops.org/releases/2026.9.0",
"fixesAdvisories": [
{
"id": "STELLA-2026-0001",
"source": "stella-product-csaf",
"cveIds": ["CVE-2026-0001"],
"summary": "Fixes signed update metadata validation.",
"advisoryUrl": "https://stella-ops.org/security/advisories/STELLA-2026-0001.json"
}
]
}
Field Definitions
| Field | Type | Required | Description |
|---|---|---|---|
schemaVersion | string | yes | Always stella.ops/productUpdateManifest@v1. Validated for exact equality. |
release | string | yes | Product release family, normally stella-ops. Validated non-blank. |
version | string | yes | Released Stella version. Validated non-blank. |
placedOnMarketAt | string date or null | yes (key present; value MAY be null) | CRA full-placement date. null for pre-full-CRA releases. The manifest validator does not parse or constrain the date value. |
madeAvailableInEu | string date or null | yes (key present; value MAY be null) | Date the release became available to EU users. The manifest validator does not parse or constrain the date value. (The Gateway product-metadata publisher, however, requires this to be a non-empty string.) |
craApplicability | enum | yes | One of pre-cra, cra-reporting-only, cra-full. Validated against this exact set. |
substantialModificationOf | string or null | yes (key present; value MAY be null) | Predecessor release if the manifest records a substantial modification. Not validated. |
supportPolicy | object | yes | Lifecycle block from product-lifecycle-v1. Validated non-null. |
images | array | yes | Non-empty image list. Each image must be digest-pinned. |
releaseNotesUrl | string | yes | Release notes URL or offline-kit relative URI. Validated non-blank. |
fixesAdvisories | array | yes (key present; MAY be empty) | Fixed-advisory summaries; empty array is allowed for non-security releases. |
Property names are emitted exactly as shown (the serializer uses PropertyNamingPolicy = null, so the [JsonPropertyName] camelCase names on the ProductUpdateManifest record graph are authoritative).
supportPolicy
| Field | Type | Required | Description |
|---|---|---|---|
lifecycleSchema | string | yes | Always stella.ops/productLifecycle@v1 for this contract (ProductUpdateManifestContract.LifecycleSchemaVersion). Validated for exact equality. |
releaseTrain | object | yes | Lifecycle record copied from the pinned lifecycle source. Validated non-null. Its version, releasedAt, securitySupportUntil, and eolAt are each validated non-blank; lts is a boolean. |
images[]
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | OCI repository or release artifact name. |
platform | string | yes | Platform string such as linux/amd64. |
digest | string | yes | Lowercase sha256:<64 hex> digest. |
sbomRef | string | yes | OCI, CAS, or offline-kit reference to the signed SBOM archive for the image. |
fixesAdvisories[]
| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | Stella advisory or feed item identifier. Validated non-blank. |
source | string | yes | Advisory source, for example stella-product-csaf. Validated non-blank. |
cveIds | array | yes (MUST be present, non-null; MAY be empty) | CVE IDs. During normalization they are trimmed, upper-cased, empty entries dropped, de-duplicated, and ordinal-sorted. Empty array is allowed when the fix is not CVE-backed. The validator does not enforce CVE-ID syntax. |
summary | string | yes | Short summary of the fixed issue (trimmed during normalization). Validated non-blank. |
advisoryUrl | string | yes | Online URL or offline-kit relative path to the advisory. Validated non-blank. |
Validation Rules
ProductUpdateManifestBuilder.Validate (invoked from Normalize) enforces the following, throwing SignerValidationException with the listed code on the first failure:
schemaVersionMUST equalstella.ops/productUpdateManifest@v1(schema_version_invalid).releaseMUST be non-blank (release_missing).versionMUST be non-blank (version_missing).craApplicabilityMUST be exactly one ofpre-cra,cra-reporting-only,cra-full(cra_applicability_invalid).releaseNotesUrlMUST be non-blank (release_notes_url_missing).supportPolicyMUST be present (support_policy_missing).supportPolicy.lifecycleSchemaMUST equalstella.ops/productLifecycle@v1(support_policy_schema_invalid).supportPolicy.releaseTrainMUST be present (support_policy_release_train_missing) with non-blankversion(support_policy_version_missing),releasedAt(support_policy_released_at_missing),securitySupportUntil(support_policy_security_until_missing), andeolAt(support_policy_eol_at_missing).imagesMUST contain at least one entry (images_missing).- For every image:
namenon-blank (image_name_missing),platformnon-blank (image_platform_missing),digestMUST besha256:<64 hex>(image_digest_invalid),sbomRefnon-blank (image_sbom_ref_missing). - For every advisory:
id(fix_advisory_id_missing),source(fix_advisory_source_missing),advisoryUrl(fix_advisory_url_missing), andsummary(fix_advisory_summary_missing) MUST be non-blank.
The signed statement and offline verifier additionally require:
- The signed in-toto statement MUST include a manifest subject whose digest equals the canonical manifest hash (offline-verifier issue
manifest_subject_digest_mismatch). - The signed in-toto statement MUST include an image subject for every manifest image digest (offline-verifier issue
image_subject_digest_missing:<name>).
Rules enforced outside the manifest validator (downstream):
- Live release manifests MUST NOT contain
TBD-*placeholders. This is not enforced byProductUpdateManifestBuilder; it is enforced by the Gateway product-metadata publisher (ProductMetadataDocumentProvider.RejectPlaceholdersinsrc/Router/StellaOps.Gateway.WebService), which rejects any string containingTBD-when loading the manifest for publication.
Not implemented as stated:
craApplicabilityis not cross-validated againstplacedOnMarketAt/madeAvailableInEu(no such check exists inProductUpdateManifestBuilder.Validate;craApplicabilityis only checked against the fixed enum set, and the date fields are not parsed). Treat the placement-anchor alignment as an authoring/process expectation, not an automated invariant.
Offline Verification
The Signer library implementation is ProductUpdateManifestOfflineVerifier.VerifyAsync, which takes a ProductUpdateManifestOfflineVerificationRequest(EnvelopeJson, TrustedPublicKeysPem, TrustRootPaths) and returns a ProductUpdateManifestOfflineVerificationResult(IsValid, Manifest, ManifestSha256, PayloadSha256, VerifiedKeyIds, Issues). It performs the following, all without network access:
- Parse the DSSE envelope JSON (
payloadType,payload,signatures[]). Signature entries accept eitherkeyid/sigorkeyId/signatureproperty spellings. Failure ->envelope_parse_error:<msg>/payload_type_missing/payload_missing/signatures_missing. - Require
payloadType == application/vnd.in-toto+json; otherwise fail withpayload_type_invalid. - Load trust roots from inline PEM strings (
TrustedPublicKeysPem) and fromTrustRootPaths(each path may be a PEM file or a directory recursively scanned for*.pem). PEM may be aPUBLIC KEYblock or aCERTIFICATE(the EC/RSA public key is extracted). Private-key PEM blocks are rejected. If no usable trust roots resolve, fail withtrust_roots_required. - Base64-decode the payload (standard or base64url accepted); failure ->
payload_invalid_base64. - Verify each DSSE signature over the PAE (
DssePreAuthenticationEncoding.Compute(payloadType, payloadBytes)) against every trust root. Supported algorithms: ECDSA with SHA-256/384/512 and RSA (PKCS1 SHA-256, PSS SHA-256/384/512). A matched signature records itskeyidinVerifiedKeyIds; unmatched signatures addsignature_invalid:<keyid>(orsignature_invalid_base64:<keyid>). If zero signatures verify, the result is invalid. - Parse the in-toto statement payload and require a non-empty
_type(statement_type_missing) andpredicateType == stella.ops/product-update-manifest@v1(predicate_type_invalid); require apredicate(predicate_missing). - Deserialize and re-normalize the
predicateinto aProductUpdateManifest, recomputeManifestSha256viaProductUpdateManifestBuilder.ComputeSha256, and validate the statementsubject[]array contains the manifest subject (stella-release-manifest:<release>:<version>) with the matchingsha256digest and an image subject per image (manifest_subject_digest_mismatch,image_subject_digest_missing:<name>). IsValidis true only when both signature verification and semantic checks produce no issues. The verifier fails closed on missing trust roots, malformed payloads, invalid signatures, digest mismatches, and unsupported schema/payload types.
Note: PayloadSha256 is the SHA-256 of the raw signed payload bytes; ManifestSha256 is the canonical hash of the re-normalized predicate. These are distinct values.
A separate generic verifier, EuArtifactOfflineVerifier.VerifyAsync (EuSigningContracts.cs), can verify this artifact via its registry entry (product-update-manifest-v1); it additionally enforces the schema-pin id, the manufacturer trust-root signer profile (stella-manufacturer-release), and an expected canonical-payload hash.
Consumers
CLI — stella verify release-manifest
The CLI exposes the offline verifier as stella verify release-manifest (VerifyCommandGroup.BuildVerifyReleaseManifestCommand in src/Cli/StellaOps.Cli/Commands/VerifyCommandGroup.cs):
- Positional argument
file— path to a DSSE product update manifest envelope. Remote (http/https) URIs are rejected; this verifier never fetches over the network. --trust-root(repeatable) — local PEM file or directory of trusted release public keys.--trusted-public-key-pem(repeatable) — inline trusted release public-key PEM.--output(table|json, defaulttable) and--json— output format. JSON emitsvalid,release,version,manifestSha256,payloadSha256,verifiedKeyIds[],issues[], andimages[].- Exit codes: success on
IsValid; otherwiseCliExitCodes.VerificationFailed(orMissingRequiredOption/FileNotFoundfor input problems).
Gateway — product security/lifecycle metadata publication
ProductMetadataDocumentProvider (src/Router/StellaOps.Gateway.WebService/ProductMetadata/) reads a signed product update manifest from disk and uses it to publish .well-known/security.txt, the product-security JSON (stella.ops/productSecurityMetadata@v1), and the support-lifecycle JSON (stella.ops/productLifecycle@v1). Publication is fail-closed and is suppressed unless all of the following hold: PublishMetadata, SealedConfig, the operational Preflight.AllPassed, ReleaseManifestSignatureVerified, a valid GeneratedAtUtc, an HTTPS CanonicalBaseUrl, and a readable manifest file. When loading the manifest it additionally requires schemaVersion == stella.ops/productUpdateManifest@v1, supportPolicy.lifecycleSchema == stella.ops/productLifecycle@v1, that version equals the running product version, that images[] covers the running image digest (when configured), a non-empty madeAvailableInEu, and rejects any TBD- placeholder string. It only consumes a subset of fields (version, placedOnMarketAt, madeAvailableInEu, craApplicability, and the releaseTrain); the manifest’s signature is verified by the upstream pipeline, not by this provider.
Revision Log
| Date (UTC) | Change | Notes |
|---|---|---|
| 2026-05-30 | Doc/code reconciliation against StellaOps.Signer.Core. | Pinned ManifestPayloadType/InTotoPayloadType constants and _type = https://in-toto.io/Statement/v0.1; expanded normalization, validation codes, and offline-verifier behavior to match ProductUpdateManifest.cs; clarified field requiredness; corrected the craApplicability cross-validation claim (not implemented); added CLI (stella verify release-manifest) and Gateway product-metadata consumer sections; noted the TBD- placeholder rule is enforced by the Gateway publisher, not the manifest builder. |
| 2026-04-30 | Updated example release train to 2026.9. | madeAvailableInEu = 2026-09-01, craApplicability = pre-cra, and lifecycle support dates run through 2031-09-01. |
| 2026-04-30 | v1 introduced. | Defines signed Stella release manifest schema and offline verifier expectations for CRA Annex I update-channel integrity. |
