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 as StellaOps.Attestation live at src/Attestor/StellaOps.Attestation/ (not src/StellaOps.Attestation). The DSSE envelope/signature value types are owned by the sibling project src/Attestor/StellaOps.Attestor.Envelope/. The operator-facing entry point is the stella attest CLI 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

TermMeaning
In-toto StatementJSON document describing what happened (predicate) to which artifact (subject).
DSSEDead Simple Signing Envelope: wraps the statement, base64 payload, and signatures.
Authority SignerStella Ops client that signs data via file-based keys, HSM/KMS, or keyless Fulcio certs.
PAEPre-Authentication Encoding: canonical “DSSEv1” byte layout that is signed.

Requirements:

  1. .NET 10 SDK for C# helper code (repo pins SDK 10.0.103 via global.json, allowPrerelease: false; the library targets net10.0).
  2. Authority key material (dev: file-based Ed25519; prod: KMS/Vault-backed signer — StellaOps.Cryptography.Kms.KmsSigner). Keyless (OIDC/Fulcio) signing is also supported by the stella attest sign --keyless flow.
  3. 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 names keyid and sig.

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 predicateType URIs in this section that begin with https://stella.ops/... are illustrative placeholders. The predicate types the CLI actually registers use the https://stellaops.io/... host (see stella attest predicates list, and §5); https://slsa.dev/provenance/v1 and https://in-toto.io/Statement/v1 are 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); use envelope.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.Tool project and no attest 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. (See src/Cli/StellaOps.Cli/Commands/CommandFactory.cs, BuildAttestCommand, and docs/modules/cli/guides/attest.md.) Write the per-step predicate body (scanner evidence, SLSA provenance, push record) to a file first, then sign it. The stella attest sign flags are: --predicate/-p, --predicate-type, --subject, --digest, --key/-k, --keyless, --rekor / --no-rekor, --timestamp, --tsa, --output/-o, --format (dsse default, or sigstore-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


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

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.