Build-time DSSE attestation walkthrough
Emit signed, in-toto-compliant DSSE envelopes for each step of a container build (scan → package → push) using Stella Ops Authority keys, then verify them offline. This guide is for pipeline and platform engineers wiring attestation into developer builds (GitHub/GitLab, dotnet builds, container scanners); the same primitives power the Stella Ops Signer/Attestor services. For where these envelopes are produced inside a release, see Release pipelines.
Status: Complete — implements the November 2025 advisory “Embed in-toto attestations (DSSE-wrapped) into .NET 10/C# builds.” Updated 2025-11-27 with CLI verification commands (
DSSE-CLI-401-021). Owners: Attestor Guild · DevOps Guild · Docs Guild.Source of truth (verified against
src/): the DSSE/in-toto helpers shipped asStellaOps.Attestationlive atsrc/Attestor/StellaOps.Attestation/(notsrc/StellaOps.Attestation). The DSSE envelope/signature value types are owned by the sibling projectsrc/Attestor/StellaOps.Attestor.Envelope/. The operator-facing entry point is thestella attestCLI command group (see §4–§5); the C# snippets in §2–§3 are an illustrative sketch of the same primitives, not a verbatim copy of the shipped API surface.
1. Concepts refresher
| Term | Meaning |
|---|---|
| In-toto Statement | JSON document describing what happened (predicate) to which artifact (subject). |
| DSSE | Dead Simple Signing Envelope: wraps the statement, base64 payload, and signatures. |
| Authority Signer | Stella Ops client that signs data via file-based keys, HSM/KMS, or keyless Fulcio certs. |
| PAE | Pre-Authentication Encoding: canonical “DSSEv1 |
Requirements:
- .NET 10 SDK for C# helper code (repo pins SDK
10.0.103viaglobal.json,allowPrerelease: false; the library targetsnet10.0). - Authority key material (dev: file-based Ed25519; prod: KMS/Vault-backed signer —
StellaOps.Cryptography.Kms.KmsSigner). Keyless (OIDC/Fulcio) signing is also supported by thestella attest sign --keylessflow. - Artifact digest (e.g.,
pkg:docker/registry/app@sha256:...) per step.
2. Helpers (shipped library)
These primitives ship in src/Attestor/StellaOps.Attestation/ (target framework net10.0). The statement/subject records below are the shipped shapes (note the [property: JsonPropertyName] mapping — the wire fields are _type, subject, predicateType, predicate, name, digest):
// src/Attestor/StellaOps.Attestation/Models.cs
public sealed record Subject(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("digest")] IReadOnlyDictionary<string, string> Digest);
public sealed record InTotoStatement(
[property: JsonPropertyName("_type")] string Type,
[property: JsonPropertyName("subject")] IReadOnlyList<Subject> Subject,
[property: JsonPropertyName("predicateType")] string PredicateType,
[property: JsonPropertyName("predicate")] object Predicate);
The DSSE envelope and signature value types are not in this library — they are owned by src/Attestor/StellaOps.Attestor.Envelope/ and re-used here:
// src/Attestor/StellaOps.Attestor.Envelope/DsseEnvelope.cs
public sealed class DsseEnvelope
{
public DsseEnvelope(
string payloadType,
ReadOnlyMemory<byte> payload, // raw bytes, NOT base64 string
IEnumerable<DsseSignature> signatures,
string? payloadContentType = null,
DsseDetachedPayloadReference? detachedPayload = null);
// Signatures are sorted deterministically (keyid asc, then signature asc).
}
// src/Attestor/StellaOps.Attestor.Envelope/DsseSignature.cs
public sealed record DsseSignature(string Signature, string? KeyId = null);
// DsseSignature.FromBytes(ReadOnlySpan<byte>, string? keyId) base64-encodes for you.
When serialised to the on-the-wire DSSE JSON (
DsseEnvelopeExtensions.ToSerializableDict), the payload is base64-encoded and each signature object uses the standard DSSE field nameskeyidandsig.
The signer abstraction (src/Attestor/StellaOps.Attestation/IAuthoritySigner.cs):
public interface IAuthoritySigner
{
Task<string> GetKeyIdAsync(CancellationToken cancellationToken = default);
Task<byte[]> SignAsync(ReadOnlyMemory<byte> paePayload, CancellationToken cancellationToken = default);
}
DSSE helper (src/Attestor/StellaOps.Attestation/DsseHelper.cs). The shipped WrapAsync takes no payloadType argument — the payload type is taken from statement.Type, and falls back to https://in-toto.io/Statement/v1 (the in-toto Statement type URI) when that is blank. There is no application/vnd.in-toto+json default here. (That media-type string is used elsewhere — by the server-side Attestor submission/Rekor pipeline — but not by this build-time helper.)
public static class DsseHelper
{
private const string DefaultPayloadType = "https://in-toto.io/Statement/v1";
public static async Task<DsseEnvelope> WrapAsync(
InTotoStatement statement,
IAuthoritySigner signer,
CancellationToken cancellationToken = default)
{
var payloadType = string.IsNullOrWhiteSpace(statement.Type) ? DefaultPayloadType : statement.Type;
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(
statement,
new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = false });
var pae = PreAuthenticationEncoding(payloadType, payloadBytes);
var signatureBytes = await signer.SignAsync(pae, cancellationToken).ConfigureAwait(false);
var keyId = await signer.GetKeyIdAsync(cancellationToken).ConfigureAwait(false);
var dsseSignature = DsseSignature.FromBytes(signatureBytes, keyId);
return new DsseEnvelope(payloadType, payloadBytes, new[] { dsseSignature });
}
// PAE layout: "DSSEv1 <len(pt)> <pt> <len(payload)> <payload>" (ASCII lengths, single spaces).
public static byte[] PreAuthenticationEncoding(string payloadType, ReadOnlySpan<byte> payload)
{
var header = Encoding.UTF8.GetBytes("DSSEv1");
var pt = Encoding.UTF8.GetBytes(payloadType);
var lenPt = Encoding.UTF8.GetBytes(pt.Length.ToString(CultureInfo.InvariantCulture));
var lenPayload = Encoding.UTF8.GetBytes(payload.Length.ToString(CultureInfo.InvariantCulture));
var space = Encoding.UTF8.GetBytes(" ");
// ... concatenate header, space, lenPt, space, pt, space, lenPayload, space, payload
// (see DsseHelper.cs for the exact buffer copy).
}
}
Authority signer examples:
/dev (file Ed25519):
public sealed class FileEd25519Signer : IAuthoritySigner, IDisposable
{
private readonly Ed25519 _ed;
private readonly string _keyId;
public FileEd25519Signer(byte[] privateKeySeed, string keyId)
{
_ed = new Ed25519(privateKeySeed);
_keyId = keyId;
}
public Task<string> GetKeyIdAsync(CancellationToken ct) => Task.FromResult(_keyId);
public Task<byte[]> SignAsync(ReadOnlyMemory<byte> pae, CancellationToken ct)
=> Task.FromResult(_ed.Sign(pae.Span.ToArray()));
public void Dispose() => _ed.Dispose();
}
Prod (Authority KMS):
Reuse the existing KMS signer (StellaOps.Cryptography.Kms.KmsSigner, at src/__Libraries/StellaOps.Cryptography.Kms/KmsSigner.cs)—wrap it behind IAuthoritySigner. On-prem default backends for the underlying KEK are file-based keys and HashiCorp Vault; do not introduce a cloud-managed KMS as a default.
3. Emitting attestations per step
The
predicateTypeURIs in this section that begin withhttps://stella.ops/...are illustrative placeholders. The predicate types the CLI actually registers use thehttps://stellaops.io/...host (seestella attest predicates list, and §5);https://slsa.dev/provenance/v1andhttps://in-toto.io/Statement/v1are real.
Subject helper (the shipped Subject record is positional Subject(Name, Digest)):
static Subject ImageSubject(string imageDigest) => new(
imageDigest,
new Dictionary<string,string>{{"sha256", imageDigest.Replace("sha256:", "", StringComparison.Ordinal)}});
3.1 Scan
var scanStmt = new InTotoStatement(
"https://in-toto.io/Statement/v1", // Type
new[]{ ImageSubject(imageDigest) }, // Subject
"https://stella.ops/predicates/scanner-evidence/v1", // PredicateType (illustrative — see note above)
new { // Predicate
scanner = "StellaOps.Scanner 0.9.0",
findingsSha256 = scanResultsHash,
startedAt = startedIso,
finishedAt = finishedIso,
rulePack = "lattice:default@2025-11-01"
});
var scanEnvelope = await DsseHelper.WrapAsync(scanStmt, signer);
// DsseEnvelope.Payload is raw bytes — serialise via the extension that base64-encodes
// the payload and emits the standard {payloadType, payload, signatures:[{keyid,sig}]} shape:
await File.WriteAllTextAsync(
"artifacts/attest-scan.dsse.json",
JsonSerializer.Serialize(scanEnvelope.ToSerializableDict()));
§3.2/§3.3 below abbreviate the write step as
JsonSerializer.Serialize(envelope); useenvelope.ToSerializableDict()(as in §3.1) for canonical DSSE JSON on the wire.
3.2 Package (SLSA provenance)
var pkgStmt = new InTotoStatement(
"https://in-toto.io/Statement/v1",
new[]{ ImageSubject(imageDigest) },
"https://slsa.dev/provenance/v1",
new {
builder = new { id = "stella://builder/dockerfile" },
buildType = "dockerfile/v1",
invocation = new { configSource = repoUrl, entryPoint = dockerfilePath },
materials = new[] { new { uri = repoUrl, digest = new { git = gitSha } } }
});
var pkgEnvelope = await DsseHelper.WrapAsync(pkgStmt, signer);
await File.WriteAllTextAsync("artifacts/attest-package.dsse.json", JsonSerializer.Serialize(pkgEnvelope));
3.3 Push
var pushStmt = new InTotoStatement(
"https://in-toto.io/Statement/v1",
new[]{ ImageSubject(imageDigest) },
"https://stella.ops/predicates/push/v1",
new { registry = registryUrl, repository = repoName, tags, pushedAt = DateTimeOffset.UtcNow });
var pushEnvelope = await DsseHelper.WrapAsync(pushStmt, signer);
await File.WriteAllTextAsync("artifacts/attest-push.dsse.json", JsonSerializer.Serialize(pushEnvelope));
4. CI integration
Reconciled with the shipped CLI. There is no
StellaOps.Attestor.Toolproject and noattest step <scan|package|push>subcommand in the codebase. Emit signed envelopes with the real command —stella attest sign— which takes a predicate JSON file plus the subject name and digest, signs a DSSE envelope, and writes it to--output. (Seesrc/Cli/StellaOps.Cli/Commands/CommandFactory.cs,BuildAttestCommand, anddocs/modules/cli/guides/attest.md.) Write the per-step predicate body (scanner evidence, SLSA provenance, push record) to a file first, then sign it. Thestella attest signflags are:--predicate/-p,--predicate-type,--subject,--digest,--key/-k,--keyless,--rekor/--no-rekor,--timestamp,--tsa,--output/-o,--format(dssedefault, orsigstore-bundle).
4.1 GitLab example
.attest-template: &attest
image: mcr.microsoft.com/dotnet/sdk:10.0
variables:
# On-prem default: file-based signing key. For prod, point --key at the KMS/Vault-backed key id.
SIGNING_KEY: "$CI_PROJECT_DIR/secrets/signing.pem"
IMAGE_DIGEST: "$CI_REGISTRY_IMAGE@${CI_COMMIT_SHA}"
attest:scan:
stage: scan
script:
# Produce artifacts/scan-predicate.json earlier in the pipeline (scanner evidence body).
- stella attest sign
--predicate artifacts/scan-predicate.json
--predicate-type https://stellaops.io/predicates/scanner-evidence/v1
--subject "$IMAGE_DIGEST"
--digest "${CI_COMMIT_SHA}"
--key "$SIGNING_KEY"
--output artifacts/attest-scan.dsse.json
artifacts:
paths: [artifacts/attest-scan.dsse.json]
attest:package:
stage: package
script:
- stella attest sign
--predicate artifacts/provenance-predicate.json
--predicate-type https://slsa.dev/provenance/v1
--subject "$IMAGE_DIGEST"
--digest "${CI_COMMIT_SHA}"
--key "$SIGNING_KEY"
--output artifacts/attest-package.dsse.json
attest:push:
stage: push
script:
- stella attest sign
--predicate artifacts/push-predicate.json
--predicate-type https://stellaops.io/predicates/push/v1
--subject "$IMAGE_DIGEST"
--digest "${CI_COMMIT_SHA}"
--key "$SIGNING_KEY"
--rekor
--output artifacts/attest-push.dsse.json
4.2 GitHub Actions snippet
jobs:
attest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Emit scan attestation
# Assumes the `stella` CLI is on PATH (installed/cached earlier in the workflow)
# and artifacts/scan-predicate.json was produced by the scan step.
run: |
stella attest sign \
--predicate artifacts/scan-predicate.json \
--predicate-type https://stellaops.io/predicates/scanner-evidence/v1 \
--subject "${{ env.IMAGE_DIGEST }}" \
--digest "${{ github.sha }}" \
--key "$SIGNING_KEY" \
--output artifacts/attest-scan.dsse.json
env:
SIGNING_KEY: ${{ secrets.SIGNING_KEY }}
5. Verification
stella attest verify --envelope artifacts/attest-scan.dsse.json— offline verification using the CLI (--envelope/-eis required).- Additional verification options (all real — see
CommandFactory.BuildAttestCommand):--policy policy.json— policy JSON file with verification rules--root keys/root.pem— trusted root certificate (PEM)--transparency-checkpoint checkpoint.json— verify against a Rekor checkpoint file--require-timestamp/--max-skew 5m— require RFC-3161 timestamp evidence and bound the skew--explain— include a per-check explanation in the report--output/-o <path>,--format/-f table|json— report destination and shape
- The DSSE verifier (
StellaOps.Attestation.DsseVerifier) accepts ECDSA, RSA, and Ed25519 public keys; trust roots can be supplied as PEM public keys or X.509 certificates and are loaded byLocalTrustRootDsseVerifier(file or directory). - Manual validation:
- Base64-decode the
payload→ ensure_type=https://in-toto.io/Statement/v1andsubject[].digest.sha256matches the artifact. (If the envelope omitspayloadType, the verifier defaults it to this same in-toto Statement URI.) - Recompute the PAE (
DSSEv1 <len> <payloadType> <len> <payload>) and verify each signature against the trusted public key. - Optionally record the envelope in Rekor: emit it with
stella attest sign --rekor, attach to an OCI artifact withstella attest attach --rekor, or submit via the server-side Attestor API.
- Base64-decode the
6. Storage conventions
Store DSSE files next to build outputs:
artifacts/
attest-scan.dsse.json
attest-package.dsse.json
attest-push.dsse.json
Include the SHA-256 digest of each envelope in promotion manifests (docs/releases/promotion-attestations.md) so downstream verifiers can trace chain of custody.
7. References
- In-toto Statement v1
- DSSE specification
docs/modules/signer/architecture.mddocs/modules/attestor/architecture.mddocs/releases/promotion-attestations.md- Shipped source:
src/Attestor/StellaOps.Attestation/(helpers),src/Attestor/StellaOps.Attestor.Envelope/(DSSE value types),src/Cli/StellaOps.Cli/Commands/CommandFactory.cs(stella attestcommand group). - Auth scopes for attestation operations (
src/Authority/.../StellaOpsScopes.cs):attest:read,attest:create,attest:admin,sbom:attest.
This file was updated as part of DSSE-LIB-401-020 and DSSE-CLI-401-021 (completed 2025-11-27). See docs/modules/cli/guides/attest.md for CI/CD workflow snippets.
