Files
git.stella-ops.org/docs/ci/dsse-build-flow.md
StellaOps Bot e901d31acf
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
up
2025-11-27 08:52:59 +02:00

9.4 KiB
Raw Blame History

Build-Time DSSE Attestation Walkthrough

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.

This guide shows how to emit signed, in-toto compliant DSSE envelopes for every container build step (scan → package → push) using StellaOps Authority keys. The same primitives power our Signer/Attestor services, but this walkthrough targets developer pipelines (GitHub/GitLab, dotnet builds, container scanners).


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 StellaOps client that signs data via file-based keys, HSM/KMS, or keyless Fulcio certs.
PAE Pre-Authentication Encoding: canonical “DSSEv1 ” byte layout that is signed.

Requirements:

  1. .NET 10 SDK (preview) for C# helper code.
  2. Authority key material (dev: file-based Ed25519; prod: Authority/KMS signer).
  3. Artifact digest (e.g., pkg:docker/registry/app@sha256:...) per step.

2. Helpers (drop-in library)

Create src/StellaOps.Attestation with:

public sealed record InTotoStatement(
    string _type,
    IReadOnlyList<Subject> subject,
    string predicateType,
    object predicate);

public sealed record Subject(string name, IReadOnlyDictionary<string,string> digest);

public sealed record DsseEnvelope(
    string payloadType,
    string payload,
    IReadOnlyList<Signature> signatures);

public sealed record Signature(string keyid, string sig);

public interface IAuthoritySigner
{
    Task<string> GetKeyIdAsync(CancellationToken ct = default);
    Task<byte[]> SignAsync(ReadOnlyMemory<byte> pae, CancellationToken ct = default);
}

DSSE helper:

public static class DsseHelper
{
    public static async Task<DsseEnvelope> WrapAsync(
        InTotoStatement statement,
        IAuthoritySigner signer,
        string payloadType = "application/vnd.in-toto+json",
        CancellationToken ct = default)
    {
        var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(statement,
            new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull });
        var pae = PreAuthEncode(payloadType, payloadBytes);

        var signature = await signer.SignAsync(pae, ct).ConfigureAwait(false);
        var keyId = await signer.GetKeyIdAsync(ct).ConfigureAwait(false);

        return new DsseEnvelope(
            payloadType,
            Convert.ToBase64String(payloadBytes),
            new[] { new Signature(keyId, Convert.ToBase64String(signature)) });
    }

    private static byte[] PreAuthEncode(string payloadType, byte[] payload)
    {
        static byte[] Cat(params byte[][] parts)
        {
            var len = parts.Sum(p => p.Length);
            var buf = new byte[len];
            var offset = 0;
            foreach (var part in parts)
            {
                Buffer.BlockCopy(part, 0, buf, offset, part.Length);
                offset += part.Length;
            }
            return buf;
        }

        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(" ");

        return Cat(header, space, lenPt, space, pt, space, lenPayload, space, payload);
    }
}

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 StellaOps.Signer.KmsSigner adapter—wrap it behind IAuthoritySigner.


3. Emitting attestations per step

Subject helper:

static Subject ImageSubject(string imageDigest) => new(
    name: imageDigest,
    digest: new Dictionary<string,string>{{"sha256", imageDigest.Replace("sha256:", "", StringComparison.Ordinal)}});

3.1 Scan

var scanStmt = new InTotoStatement(
    _type: "https://in-toto.io/Statement/v1",
    subject: new[]{ ImageSubject(imageDigest) },
    predicateType: "https://stella.ops/predicates/scanner-evidence/v1",
    predicate: new {
        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);
await File.WriteAllTextAsync("artifacts/attest-scan.dsse.json", JsonSerializer.Serialize(scanEnvelope));

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

4.1 GitLab example

.attest-template: &attest
  image: mcr.microsoft.com/dotnet/sdk:10.0-preview
  before_script:
    - dotnet build src/StellaOps.Attestation/StellaOps.Attestation.csproj
  variables:
    AUTHORITY_KEY_FILE: "$CI_PROJECT_DIR/secrets/ed25519.key"
    IMAGE_DIGEST: "$CI_REGISTRY_IMAGE@${CI_COMMIT_SHA}"

attest:scan:
  stage: scan
  script:
    - dotnet run --project tools/StellaOps.Attestor.Tool -- step scan --subject "$IMAGE_DIGEST" --out artifacts/attest-scan.dsse.json
  artifacts:
    paths: [artifacts/attest-scan.dsse.json]

attest:package:
  stage: package
  script:
    - dotnet run --project tools/StellaOps.Attestor.Tool -- step package --subject "$IMAGE_DIGEST" --out artifacts/attest-package.dsse.json

attest:push:
  stage: push
  script:
    - dotnet run --project tools/StellaOps.Attestor.Tool -- step push --subject "$IMAGE_DIGEST" --registry "$CI_REGISTRY" --tags "$CI_COMMIT_REF_NAME"

4.2 GitHub Actions snippet

jobs:
  attest:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with: { dotnet-version: '10.0.x' }
      - name: Build attestor helpers
        run: dotnet build src/StellaOps.Attestation/StellaOps.Attestation.csproj
      - name: Emit scan attestation
        run: dotnet run --project tools/StellaOps.Attestor.Tool -- step scan --subject "${{ env.IMAGE_DIGEST }}" --out artifacts/attest-scan.dsse.json
        env:
          AUTHORITY_KEY_REF: ${{ secrets.AUTHORITY_KEY_REF }}

5. Verification

  • stella attest verify --envelope artifacts/attest-scan.dsse.json — offline verification using the CLI.
  • Additional verification options:
    • --policy policy.json — apply custom verification policy
    • --root keys/root.pem — specify trusted root certificate
    • --transparency-checkpoint checkpoint.json — verify against Rekor checkpoint
  • Manual validation:
    1. Base64 decode payload → ensure _type = https://in-toto.io/Statement/v1, subject[].digest.sha256 matches artifact.
    2. Recompute PAE and verify signature with the Authority public key.
    3. Attach envelope to Rekor (optional) via existing Attestor API.

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/release/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.