Files
git.stella-ops.org/docs/product-advisories/30-Dec-2025 - Binary Diff Signatures for Patch Detection.md
2025-12-30 16:05:16 +02:00

37 KiB
Raw Blame History

Heres a compact, practical blueprint for detecting patched vulnerabilities in Linux binaries—even when distros backport fixes and version strings lie—by using lowentropy delta signatures over ELF segments.


Why this helps

Version numbers and CVE notes are noisy. Distros often backport a fix without bumping upstream versions. A fast, reproducible codedelta fingerprint lets StellaOps verify “is the fix present in this binary?” without trusting package metadata.

Core idea (plain English)

  1. Extract the binarys executable/readonly segments (.text, .rodata, and optionally .got/.plt).
  2. Normalize away relocation noise (addresses, section offsets, buildIDs).
  3. Hash the normalized bytes with SHA256.
  4. Compare that hash (or a tiny set of chunk hashes) against a reference signature for the patched and vulnerable states of the function(s) touched by the CVE.
  5. Verdict: “Patched”, “Still vulnerable”, or “Indeterminate (needs deeper diff)”.

What we store (the “delta signature”)

For each CVE fix (per arch, per compiler family if needed):

  • Target ELF: soname, symbol(s) affected (e.g., ssl3_accept, png_handle_iCCP)

  • Segment scope: .text (always), .rodata (optional)

  • Normalization recipe: strip relocations, zero relocation slots, canonicalize NOP runs, normalize jump tables

  • Hash schema:

    • Wholeregion SHA256, and/or
    • Rolling chunk hashes (e.g., 14 KiB windows) for resilience
  • Build meta: arch (x86_64, aarch64), endianness, ABI hints, compiler hardening flags that dont affect normalized output

  • Confidence notes (e.g., “ROPsafe change only in function X”)

Normalization rules (pragmatic)

  • Apply relocations to get final bytes, then zero out relocation targets and absolute addresses.

  • Canonicalize:

    • NOP sleds → single NOP
    • PLT/GOT stub patterns → canonical tokens
    • Jump tables: rewrite 32/64bit absolute entries to relative deltas then zero base
    • Padding → zero
  • Strip .comment, .note.*, .symtab (keep .dynsym for symbol mapping only).

Minimal C# sketch (StellaOps scanner.webservice)

public sealed class ElfDeltaHasher
{
    public record SignatureSpec(string SoName, string[] Symbols, string Arch, bool IncludeRodata);

    public record DeltaResult(string Arch, string SoName, string Symbol,
                              string Alg, byte[] Hash, int Size);

    public static IEnumerable<DeltaResult> Compute(Stream elfStream, SignatureSpec spec)
    {
        var elf = ElfFile.Load(elfStream); // use an internal reader (no P/Invoke)
        if (elf.DynamicLibraries?.Any(s => s.Contains(spec.SoName)) == false && 
            elf.SONAME != spec.SoName) yield break;

        foreach (var sym in ResolveSymbols(elf, spec.Symbols))
        {
            var textBytes = ExtractNormalized(elf, sym, sections: new[]{".text"});
            yield return HashChunk("sha256:text", spec.SoName, sym.Name, textBytes);

            if (spec.IncludeRodata)
            {
                var roBytes = ExtractNormalized(elf, sym, sections: new[]{".rodata",".rodata.rel.ro"});
                yield return HashChunk("sha256:ro", spec.SoName, sym.Name, roBytes);
            }
        }
    }

    static DeltaResult HashChunk(string alg, string so, string sym, ReadOnlySpan<byte> bytes)
    {
        using var sha = System.Security.Cryptography.SHA256.Create();
        var h = sha.ComputeHash(bytes.ToArray());
        return new DeltaResult(arch: RuntimeArch(), so, sym, alg, h, bytes.Length);
    }
}

(Your real code: implement ElfFile, relocation application, and the normalization passes above; add rolling 2KiB window hashes for robustness.)

Where it lives in StellaOps

  • StellaOps.Feedser: ingest vendor advisories; map CVE → touched symbols/functions.
  • StellaOps.PatchSigRepo (new): gitbacked registry of delta signatures (AGPL data pack).
  • StellaOps.Scanner.Webservice: runs the normalizer/hasher against container layers and host files.
  • StellaOps.Vexer: emits machineverifiable VEX statements (“Status=Fixed; Evidence=DeltaSig#abcd…”).
  • UI: in the Finding details drawer → “Patched by backport (verified by code delta)”; badge links to the exact signature entry.

Pipeline (endtoend)

  1. Signature authoring (onetime per CVE):

    • Pull upstream vulnerable & fixed tags.
    • Build both with common flags per arch; disasm diff to identify changed symbols.
    • Generate normalized hashes → create cves/CVE-YYYY-NNNN/*.json.
  2. CI publish: sign each signature file (DSSE), push to ProofMarket Ledger mirror and internal registry.

  3. Scan time:

    • Identify candidate ELF (soname/path heuristics).
    • Compute normalized hashes; compare against patched and vulnerable sets.
    • Emit VEX + attach proof artifact (hash, recipe, tool version).
  4. Policy:

    • Gate releases on “Patched OR NotAffected by reachability”, otherwise block.

Handling distro drift

  • Keep multiple signatures per CVE:

    • gccO2, clangO2, -fstack-protector-strong, LTO on/off.
  • Prefer symbolscoped hashing over whole file.

  • If symbol names are stripped:

    • Fallback to function boundary recovery (linear sweep + prologue heuristics) and fuzzy block graph matching; still run normalization.

Performance tips

  • Cache perlayer ELF normalization by (fileDigest, toolVersion).
  • Use mmap + streaming SHA256; avoid copying.
  • Parallelize perELF, but cap CPU with a scheduler token.

Reducing false results

  • Require dual evidence when uncertain: .text hash match + small CFG checksum (basicblock count + edge hash).

  • Mark Indeterminate if:

    • Only .rodata matches,
    • File is PIE with heavy inlining changes,
    • LTO changed layout dramatically.
  • Always record tool + recipe versions in the evidence blob for deterministic replay.

Data format (example)

{
  "cve": "CVE-2023-12345",
  "package": "libfoo.so.1",
  "arch": "x86_64",
  "symbols": [
    {
      "name": "foo_parse",
      "scope": ".text",
      "normalization": ["applyRelocs","zeroAbs","canonNops","canonJt"],
      "hash": "sha256:4f1c...d9",
      "size": 1184
    }
  ],
  "alt_signatures": [
    {"compiler":"clang-16","flags":"-O3 -fno-plt", "hash":"sha256:9ab...42"}
  ],
  "attestation": {"dsse":"..."}
}

Rollout checklist (2 sprints)

  • Sprint A

    • Implement ELF loader + normalization passes (x86_64/aarch64).
    • Build authoring tool (stella-deltasig mk) and signer.
    • Wire Scanner → Vexer evidence pipe; UI badge.
  • Sprint B

    • Add rolling chunk hashes + CFG microchecksum.
    • Author top20 backported CVEs (OpenSSL, glibc, zlib, curl, libpng).
    • Cache & perf; add airgapped bundle for signatures.

Why this is a moat

  • Competing scanners mostly trust CVE string matching and package versions. You ship cryptographic, functionlevel proof that a fix is present—even across diverged distros—without humans in the loop. Thats hard to replicate quickly.

If you want, I can draft the PatchSigRepo JSON schema, the DSSE attestation type, and a tiny CLI (stella-deltasig) you can drop into scanner.webservice today. Below is a complete, opinionated “DeltaSig” module design that plugs into the existing Stella Ops standard CLI (single stella entrypoint), plus the payload schema, DSSE envelope type, and a deterministic “sigpack” format. All examples and code are .NET 10style and keep dependencies minimal (built-in crypto + System.Text.Json).


1) CLI module shape (as a first-class stella module)

Command tree

stella deltasig
  mk         Create an unsigned delta-signature payload JSON
  sign       Wrap payload in DSSE + sign
  verify     Verify DSSE envelope(s)
  id         Print signature ID (sha256 of canonical payload)
  pack       Build deterministic sigpack (zip) from DSSE envelopes
  inspect    Pretty-print payload/envelope; show fields + hashes
  match      Compute evidence for a given ELF against a sigpack (optional, uses extractor service)

Output/UX rules (standard CLI conventions)

  • Default output is human-readable; add --json on any command to emit machine JSON.

  • Exit codes:

    • 0 success / verified / match found
    • 2 verification failed / match is “vulnerable”
    • 3 indeterminate / partial
    • 64 usage error (missing args)
    • 70 internal error

Typical workflow

# 1) Create payload
stella deltasig mk \
  --cve CVE-2023-12345 \
  --package libfoo \
  --soname libfoo.so.1 \
  --arch x86_64 \
  --abi gnu \
  --symbol foo_parse --symbol foo_init \
  --scope text \
  --hash sha256 \
  --out cves/CVE-2023-12345/libfoo/x86_64-gnu.payload.json

# 2) Sign (DSSE)
stella deltasig sign \
  --in  cves/CVE-2023-12345/libfoo/x86_64-gnu.payload.json \
  --key keys/stellaops-patches.ecdsa-p256.pem \
  --out cves/CVE-2023-12345/libfoo/x86_64-gnu.dsse.json

# 3) Verify
stella deltasig verify \
  --in  cves/CVE-2023-12345/libfoo/x86_64-gnu.dsse.json \
  --pub keys/stellaops-patches.ecdsa-p256.pub.pem

# 4) Pack for offline distribution
stella deltasig pack \
  --in-dir cves/ \
  --out stellaops-deltasigpack-2025-12.zip

2) File formats

2.1 Delta signature payload (canonical JSON, signed via DSSE)

PayloadType (DSSE): application/vnd.stellaops.deltasig.v1+json

Design goals

  • Payload is deterministic: no timestamps, no author fields.
  • Signature ID is sha256(canonical_payload_bytes).

Payload structure (DeltaSigPayloadV1)

{
  "schema": "stellaops.deltasig.v1",
  "cve": "CVE-2023-12345",
  "package": {
    "name": "libfoo",
    "soname": "libfoo.so.1"
  },
  "target": {
    "arch": "x86_64",
    "abi": "gnu"
  },
  "normalization": {
    "recipeId": "elf.delta.norm.v1",
    "steps": [
      "applyRelocs",
      "zeroRelocTargets",
      "canonNops",
      "canonPltGot",
      "canonJumpTables",
      "zeroPadding"
    ]
  },
  "symbols": [
    {
      "name": "foo_parse",
      "scope": ".text",
      "hashAlg": "sha256",
      "hashHex": "4f1c...d9",
      "sizeBytes": 1184,
      "chunks": [
        { "offset": 0, "sizeBytes": 2048, "hashHex": "aa.." }
      ],
      "cfg": { "bbCount": 42, "edgeHashHex": "bb.." }
    }
  ]
}

Notes:

  • chunks and cfg are optional but recommended for resilience.
  • hashHex is lowercase hex of the normalized bytes of the symbol region.

2.2 DSSE envelope (signed wrapper)

Minimal DSSE JSON:

{
  "payloadType": "application/vnd.stellaops.deltasig.v1+json",
  "payload": "BASE64URL( canonical_payload_bytes )",
  "signatures": [
    {
      "keyid": "sha256:ab12...ef",
      "sig": "BASE64URL( signature_over_PAE )"
    }
  ]
}

2.3 Sigpack (deterministic offline bundle)

A ZIP with deterministic ordering and timestamps forced to a constant (e.g., 1980-01-01 for ZIP compatibility).

Contents:

index.json
sigs/<sigId>.dsse.json
(optional) keys/<keyid>.pub.pem
(optional) meta.json

index.json (sorted by sigId):

{
  "schema": "stellaops.deltasigpack.v1",
  "entries": [
    {
      "sigId": "sha256:...",
      "cve": "CVE-2023-12345",
      "package": "libfoo",
      "soname": "libfoo.so.1",
      "arch": "x86_64",
      "abi": "gnu",
      "path": "sigs/sha256-....dsse.json"
    }
  ]
}

3) How to integrate as a module in the Stella Ops standard CLI

3.1 Minimal module contract (stable SPI)

Create a tiny contract assembly used by the main CLI and all modules:

src/StellaOps.Cli.Abstractions/IStellaCliModule.cs

namespace StellaOps.Cli.Abstractions;

public interface IStellaCliModule
{
    string Name { get; }          // "deltasig"
    string Description { get; }   // one-liner
    void Register(InvocationContext ctx, Command root);
}

InvocationContext can carry DI/services/config:

namespace StellaOps.Cli.Abstractions;

public sealed record InvocationContext(
    IServiceProvider Services,
    string[] Args,
    TextWriter Out,
    TextWriter Err
);

3.2 Main CLI loads modules (built-in + plugin folder)

src/StellaOps.Cli/Program.cs

using System.CommandLine;
using System.CommandLine.Invocation;
using System.Reflection;
using System.Runtime.Loader;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using StellaOps.Cli.Abstractions;

var builder = Host.CreateApplicationBuilder(args);

// Register shared services (logging, config, etc.)
builder.Services.AddSingleton<TextWriter>(_ => Console.Out);
builder.Services.AddSingleton<TextWriter>(_ => Console.Error);

// Load built-in modules by referencing their assemblies,
// plus optional external modules from "./modules".
var modules = LoadModules(AppContext.BaseDirectory);

var host = builder.Build();
var services = host.Services;

var root = new RootCommand("Stella Ops CLI");

var ctx = new InvocationContext(services, args, Console.Out, Console.Error);

foreach (var m in modules.OrderBy(m => m.Name, StringComparer.Ordinal))
    m.Register(ctx, root);

return await root.InvokeAsync(args);

static IReadOnlyList<IStellaCliModule> LoadModules(string baseDir)
{
    var result = new List<IStellaCliModule>();

    // 1) Built-in: scan already loaded assemblies
    result.AddRange(InstantiateModules(AppDomain.CurrentDomain.GetAssemblies()));

    // 2) Plugins: ./modules/*.dll
    var modulesDir = Path.Combine(baseDir, "modules");
    if (Directory.Exists(modulesDir))
    {
        foreach (var dll in Directory.EnumerateFiles(modulesDir, "*.dll", SearchOption.TopDirectoryOnly)
                                     .OrderBy(x => x, StringComparer.Ordinal))
        {
            try
            {
                var asm = AssemblyLoadContext.Default.LoadFromAssemblyPath(dll);
                result.AddRange(InstantiateModules(new[] { asm }));
            }
            catch
            {
                // keep CLI robust: ignore broken plugins
            }
        }
    }

    // Deduplicate by Name (first wins)
    return result.GroupBy(m => m.Name, StringComparer.Ordinal).Select(g => g.First()).ToList();
}

static IEnumerable<IStellaCliModule> InstantiateModules(IEnumerable<Assembly> assemblies)
{
    foreach (var asm in assemblies)
    {
        Type[] types;
        try { types = asm.GetTypes(); }
        catch { continue; }

        foreach (var t in types)
        {
            if (t.IsAbstract || t.IsInterface) continue;
            if (!typeof(IStellaCliModule).IsAssignableFrom(t)) continue;
            if (t.GetConstructor(Type.EmptyTypes) is null) continue;

            IStellaCliModule? module = null;
            try { module = (IStellaCliModule)Activator.CreateInstance(t)!; }
            catch { /* ignore */ }

            if (module is not null) yield return module;
        }
    }
}

This approach keeps “module = assembly” without forcing a rigid plugin framework.


4) The DeltaSig module implementation (CLI wiring)

4.1 Project layout

src/
  StellaOps.Cli/
  StellaOps.Cli.Abstractions/
  StellaOps.PatchSig/                 # models + canonical JSON + DSSE + pack builder
  StellaOps.Cli.Modules.DeltaSig/      # the CLI module, depends on PatchSig

4.2 Module registration

src/StellaOps.Cli.Modules.DeltaSig/DeltaSigModule.cs

using System.CommandLine;
using StellaOps.Cli.Abstractions;
using StellaOps.PatchSig;

namespace StellaOps.Cli.Modules.DeltaSig;

public sealed class DeltaSigModule : IStellaCliModule
{
    public string Name => "deltasig";
    public string Description => "Create, sign, verify, and pack patch-aware ELF delta signatures.";

    public void Register(InvocationContext ctx, Command root)
    {
        var cmd = new Command("deltasig", Description);

        cmd.AddCommand(DeltaSigCommands.Mk());
        cmd.AddCommand(DeltaSigCommands.Sign());
        cmd.AddCommand(DeltaSigCommands.Verify());
        cmd.AddCommand(DeltaSigCommands.Id());
        cmd.AddCommand(DeltaSigCommands.Pack());
        cmd.AddCommand(DeltaSigCommands.Inspect());
        // cmd.AddCommand(DeltaSigCommands.Match()); // optional (requires ELF extractor service)

        root.AddCommand(cmd);

        // Optional alias: "stella sig delta ..."
        var sig = root.Subcommands.FirstOrDefault(c => c.Name == "sig");
        if (sig is not null)
        {
            var deltaAlias = new Command("delta", Description);
            foreach (var sc in cmd.Subcommands) deltaAlias.AddCommand(sc);
            sig.AddCommand(deltaAlias);
        }
    }
}

4.3 Commands (handlers call PatchSig library)

src/StellaOps.Cli.Modules.DeltaSig/DeltaSigCommands.cs

using System.CommandLine;
using System.CommandLine.NamingConventionBinder;
using StellaOps.PatchSig;
using StellaOps.PatchSig.Dsse;
using StellaOps.PatchSig.Packaging;

namespace StellaOps.Cli.Modules.DeltaSig;

internal static class DeltaSigCommands
{
    public static Command Mk()
    {
        var c = new Command("mk", "Create an unsigned delta-signature payload JSON.");
        var cve     = new Option<string>("--cve") { IsRequired = true };
        var pkg     = new Option<string>("--package") { IsRequired = true };
        var soname  = new Option<string>("--soname") { IsRequired = true };
        var arch    = new Option<string>("--arch") { IsRequired = true };
        var abi     = new Option<string>("--abi", () => "gnu");
        var symbols = new Option<string[]>("--symbol") { AllowMultipleArgumentsPerToken = true, IsRequired = true };
        var scope   = new Option<string>("--scope", () => "text"); // text|rodata|both
        var outFile = new Option<string>("--out") { IsRequired = true };

        c.AddOption(cve); c.AddOption(pkg); c.AddOption(soname); c.AddOption(arch); c.AddOption(abi);
        c.AddOption(symbols); c.AddOption(scope); c.AddOption(outFile);

        c.Handler = CommandHandler.Create<string,string,string,string,string,string[],string,string>((cve, package, soname, arch, abi, symbol, scope, @out) =>
        {
            // IMPORTANT: mk here creates the *structure*. Filling hashes is typically done by an extractor.
            // If you have an ELF extractor service, plug it here to compute hashHex/sizeBytes/chunks/cfg.
            var payload = DeltaSigPayloadV1.CreateSkeleton(cve, package, soname, arch, abi, symbol, scope);

            var bytes = CanonicalJson.Serialize(payload);
            Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(@out))!);
            File.WriteAllBytes(@out, bytes);
            return 0;
        });

        return c;
    }

    public static Command Id()
    {
        var c = new Command("id", "Print signature ID (sha256 of canonical payload).");
        var input = new Option<string>("--in") { IsRequired = true };
        c.AddOption(input);

        c.Handler = CommandHandler.Create<string>((@in) =>
        {
            var payloadBytes = File.ReadAllBytes(@in);
            var sigId = SigId.ComputeSha256Hex(payloadBytes);
            Console.Out.WriteLine($"sha256:{sigId}");
            return 0;
        });

        return c;
    }

    public static Command Sign()
    {
        var c = new Command("sign", "Wrap payload in DSSE and sign.");
        var input = new Option<string>("--in") { IsRequired = true };
        var key   = new Option<string>("--key") { IsRequired = true };
        var outp  = new Option<string>("--out") { IsRequired = true };
        var alg   = new Option<string>("--alg", () => "ecdsa-p256-sha256"); // ecdsa-p256-sha256|rsa-pss-sha256
        c.AddOption(input); c.AddOption(key); c.AddOption(outp); c.AddOption(alg);

        c.Handler = CommandHandler.Create<string,string,string,string>((@in, key, @out, alg) =>
        {
            var payloadBytes = File.ReadAllBytes(@in);

            var signer = DsseSignerFactory.CreateFromPem(key, alg);
            var env = DsseEnvelope.CreateSigned(
                payloadType: DsseTypes.DeltaSigV1,
                payloadBytes: payloadBytes,
                signer: signer
            );

            var envBytes = CanonicalJson.Serialize(env);
            Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(@out))!);
            File.WriteAllBytes(@out, envBytes);
            return 0;
        });

        return c;
    }

    public static Command Verify()
    {
        var c = new Command("verify", "Verify DSSE envelope signature(s).");
        var input = new Option<string>("--in") { IsRequired = true };
        var pub   = new Option<string>("--pub") { IsRequired = true };
        c.AddOption(input); c.AddOption(pub);

        c.Handler = CommandHandler.Create<string,string>((@in, pub) =>
        {
            var envBytes = File.ReadAllBytes(@in);
            var env = CanonicalJson.Deserialize<DsseEnvelope>(envBytes);

            var verifier = DsseVerifierFactory.CreateFromPem(pub);

            var ok = env.VerifyAny(verifier, out var matchedKeyId);
            if (!ok)
            {
                Console.Error.WriteLine("DSSE verification FAILED.");
                return 2;
            }

            Console.Out.WriteLine($"DSSE verification OK. keyid={matchedKeyId}");
            return 0;
        });

        return c;
    }

    public static Command Inspect()
    {
        var c = new Command("inspect", "Inspect payload or DSSE envelope.");
        var input = new Option<string>("--in") { IsRequired = true };
        c.AddOption(input);

        c.Handler = CommandHandler.Create<string>((@in) =>
        {
            var bytes = File.ReadAllBytes(@in);

            // Heuristic: DSSE envelope contains "payloadType"
            if (bytes.AsSpan().IndexOf(System.Text.Encoding.UTF8.GetBytes("\"payloadType\"")) >= 0)
            {
                var env = CanonicalJson.Deserialize<DsseEnvelope>(bytes);
                Console.Out.WriteLine($"payloadType: {env.PayloadType}");
                Console.Out.WriteLine($"payloadBytes: {Base64Url.Decode(env.Payload).Length}");
                Console.Out.WriteLine($"signatures: {env.Signatures.Count}");
            }
            else
            {
                var payload = CanonicalJson.Deserialize<DeltaSigPayloadV1>(bytes);
                Console.Out.WriteLine($"{payload.Cve} {payload.Package.Name} {payload.Target.Arch}-{payload.Target.Abi}");
                Console.Out.WriteLine($"symbols: {payload.Symbols.Count}");
            }

            return 0;
        });

        return c;
    }

    public static Command Pack()
    {
        var c = new Command("pack", "Build deterministic sigpack (zip) from DSSE envelopes under a directory.");
        var inDir = new Option<string>("--in-dir") { IsRequired = true };
        var outp  = new Option<string>("--out") { IsRequired = true };
        c.AddOption(inDir); c.AddOption(outp);

        c.Handler = CommandHandler.Create<string,string>((inDir, @out) =>
        {
            SigPackBuilder.BuildFromDirectory(inDir, @out);
            Console.Out.WriteLine(@out);
            return 0;
        });

        return c;
    }
}

The module is “real”: mk/sign/verify/id/pack/inspect are fully implementable now. match can be added once you plug in the ELF delta extractor you already want in scanner.webservice.


5) PatchSig library: canonical JSON, signature IDs, DSSE sign/verify, deterministic pack

5.1 Canonical JSON (field-order deterministic)

You control determinism by writing properties in a fixed order (no reliance on reflection order).

src/StellaOps.PatchSig/CanonicalJson.cs

using System.Text;
using System.Text.Json;

namespace StellaOps.PatchSig;

public static class CanonicalJson
{
    private static readonly JsonSerializerOptions Options = new()
    {
        WriteIndented = false,
        PropertyNamingPolicy = null,
        DictionaryKeyPolicy = null
    };

    // For payload/envelope models where property order matters, implement custom writers.
    public static byte[] Serialize<T>(T value)
        => JsonSerializer.SerializeToUtf8Bytes(value, Options);

    public static T Deserialize<T>(byte[] utf8)
        => JsonSerializer.Deserialize<T>(utf8, Options)
           ?? throw new InvalidOperationException("Invalid JSON.");
}

public static class SigId
{
    public static string ComputeSha256Hex(ReadOnlySpan<byte> canonicalPayloadBytes)
    {
        var hash = System.Security.Cryptography.SHA256.HashData(canonicalPayloadBytes);
        return Convert.ToHexString(hash).ToLowerInvariant();
    }
}

If you want strict canonicalization beyond fixed-order models (e.g., arbitrary JSON), replace Serialize with a JCS implementation; for DeltaSig you typically dont need that if you own the writer.

5.2 Payload model (deterministic; no timestamps)

src/StellaOps.PatchSig/DeltaSigPayloadV1.cs

namespace StellaOps.PatchSig;

public sealed record DeltaSigPayloadV1(
    string Schema,
    string Cve,
    PackageRef Package,
    TargetRef Target,
    NormalizationRef Normalization,
    List<SymbolSig> Symbols
)
{
    public static DeltaSigPayloadV1 CreateSkeleton(
        string cve, string package, string soname, string arch, string abi,
        IEnumerable<string> symbols, string scope)
    {
        var steps = new[]
        {
            "applyRelocs",
            "zeroRelocTargets",
            "canonNops",
            "canonPltGot",
            "canonJumpTables",
            "zeroPadding"
        };

        var symScope = scope switch
        {
            "text" => ".text",
            "rodata" => ".rodata",
            "both" => ".text", // skeleton puts .text; extractor can add rodata sigs too
            _ => ".text"
        };

        return new DeltaSigPayloadV1(
            Schema: "stellaops.deltasig.v1",
            Cve: cve,
            Package: new PackageRef(package, soname),
            Target: new TargetRef(arch, abi),
            Normalization: new NormalizationRef("elf.delta.norm.v1", steps.ToList()),
            Symbols: symbols.Distinct(StringComparer.Ordinal).OrderBy(x => x, StringComparer.Ordinal)
                           .Select(s => new SymbolSig(
                               Name: s,
                               Scope: symScope,
                               HashAlg: "sha256",
                               HashHex: "",
                               SizeBytes: 0,
                               Chunks: null,
                               Cfg: null))
                           .ToList()
        );
    }
}

public sealed record PackageRef(string Name, string Soname);
public sealed record TargetRef(string Arch, string Abi);
public sealed record NormalizationRef(string RecipeId, List<string> Steps);

public sealed record SymbolSig(
    string Name,
    string Scope,
    string HashAlg,
    string HashHex,
    int SizeBytes,
    List<ChunkSig>? Chunks,
    CfgSig? Cfg
);

public sealed record ChunkSig(int Offset, int SizeBytes, string HashHex);
public sealed record CfgSig(int BbCount, string EdgeHashHex);

5.3 DSSE sign/verify (PAE included)

src/StellaOps.PatchSig/Dsse/DsseEnvelope.cs

using System.Text;

namespace StellaOps.PatchSig.Dsse;

public static class DsseTypes
{
    public const string DeltaSigV1 = "application/vnd.stellaops.deltasig.v1+json";
}

public sealed record DsseEnvelope(
    string PayloadType,
    string Payload,
    List<DsseSignature> Signatures
)
{
    public static DsseEnvelope CreateSigned(string payloadType, byte[] payloadBytes, IDsseSigner signer)
    {
        var pae = DssePae.Encode(payloadType, payloadBytes);
        var sig = signer.Sign(pae);

        return new DsseEnvelope(
            PayloadType: payloadType,
            Payload: Base64Url.Encode(payloadBytes),
            Signatures: new List<DsseSignature>
            {
                new DsseSignature(signer.KeyId, Base64Url.Encode(sig))
            }
        );
    }

    public bool VerifyAny(IDsseVerifier verifier, out string? matchedKeyId)
    {
        matchedKeyId = null;
        var payloadBytes = Base64Url.Decode(Payload);
        var pae = DssePae.Encode(PayloadType, payloadBytes);

        foreach (var s in Signatures)
        {
            var sigBytes = Base64Url.Decode(s.Sig);
            if (verifier.Verify(pae, sigBytes))
            {
                matchedKeyId = s.KeyId;
                return true;
            }
        }
        return false;
    }
}

public sealed record DsseSignature(string KeyId, string Sig);

public static class DssePae
{
    // DSSEv1 PAE: "DSSEv1" SP len(pt) SP pt SP len(payload) SP payload
    public static byte[] Encode(string payloadType, ReadOnlySpan<byte> payload)
    {
        var ptBytes = Encoding.UTF8.GetBytes(payloadType);
        var header = $"DSSEv1 {ptBytes.Length} {payloadType} {payload.Length} ";
        var headerBytes = Encoding.UTF8.GetBytes(header);

        var outBytes = new byte[headerBytes.Length + payload.Length];
        Buffer.BlockCopy(headerBytes, 0, outBytes, 0, headerBytes.Length);
        payload.CopyTo(outBytes.AsSpan(headerBytes.Length));
        return outBytes;
    }
}

public static class Base64Url
{
    public static string Encode(ReadOnlySpan<byte> bytes)
        => Convert.ToBase64String(bytes.ToArray())
                  .TrimEnd('=').Replace('+', '-').Replace('/', '_');

    public static byte[] Decode(string s)
    {
        s = s.Replace('-', '+').Replace('_', '/');
        switch (s.Length % 4)
        {
            case 2: s += "=="; break;
            case 3: s += "="; break;
        }
        return Convert.FromBase64String(s);
    }
}

public interface IDsseSigner
{
    string KeyId { get; }
    byte[] Sign(ReadOnlySpan<byte> pae);
}

public interface IDsseVerifier
{
    bool Verify(ReadOnlySpan<byte> pae, ReadOnlySpan<byte> signature);
}

src/StellaOps.PatchSig/Dsse/DsseSignerFactory.cs

using System.Security.Cryptography;
using System.Text;

namespace StellaOps.PatchSig.Dsse;

public static class DsseSignerFactory
{
    public static IDsseSigner CreateFromPem(string pemPath, string alg)
    {
        var pem = File.ReadAllText(pemPath, Encoding.UTF8);

        return alg switch
        {
            "ecdsa-p256-sha256" => new EcdsaP256Signer(pem),
            "rsa-pss-sha256"    => new RsaPssSigner(pem),
            _ => throw new ArgumentOutOfRangeException(nameof(alg), $"Unsupported alg: {alg}")
        };
    }

    private sealed class EcdsaP256Signer : IDsseSigner
    {
        private readonly ECDsa _key;
        public string KeyId { get; }

        public EcdsaP256Signer(string pem)
        {
            _key = ECDsa.Create();
            _key.ImportFromPem(pem);

            // keyid = sha256(spki-der)
            var spki = _key.ExportSubjectPublicKeyInfo();
            KeyId = "sha256:" + Convert.ToHexString(SHA256.HashData(spki)).ToLowerInvariant();
        }

        public byte[] Sign(ReadOnlySpan<byte> pae)
            => _key.SignData(pae.ToArray(), HashAlgorithmName.SHA256);
    }

    private sealed class RsaPssSigner : IDsseSigner
    {
        private readonly RSA _key;
        public string KeyId { get; }

        public RsaPssSigner(string pem)
        {
            _key = RSA.Create();
            _key.ImportFromPem(pem);

            var spki = _key.ExportSubjectPublicKeyInfo();
            KeyId = "sha256:" + Convert.ToHexString(SHA256.HashData(spki)).ToLowerInvariant();
        }

        public byte[] Sign(ReadOnlySpan<byte> pae)
            => _key.SignData(pae.ToArray(), HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
    }
}

public static class DsseVerifierFactory
{
    public static IDsseVerifier CreateFromPem(string pemPath)
    {
        var pem = File.ReadAllText(pemPath, Encoding.UTF8);

        // Heuristic: if PEM contains "RSA" use RSA, else ECDSA.
        if (pem.Contains("BEGIN RSA", StringComparison.OrdinalIgnoreCase))
            return new RsaPssVerifier(pem);

        return new EcdsaVerifier(pem);
    }

    private sealed class EcdsaVerifier : IDsseVerifier
    {
        private readonly ECDsa _pub;
        public EcdsaVerifier(string pem)
        {
            _pub = ECDsa.Create();
            _pub.ImportFromPem(pem);
        }

        public bool Verify(ReadOnlySpan<byte> pae, ReadOnlySpan<byte> signature)
            => _pub.VerifyData(pae.ToArray(), signature.ToArray(), HashAlgorithmName.SHA256);
    }

    private sealed class RsaPssVerifier : IDsseVerifier
    {
        private readonly RSA _pub;
        public RsaPssVerifier(string pem)
        {
            _pub = RSA.Create();
            _pub.ImportFromPem(pem);
        }

        public bool Verify(ReadOnlySpan<byte> pae, ReadOnlySpan<byte> signature)
            => _pub.VerifyData(pae.ToArray(), signature.ToArray(), HashAlgorithmName.SHA256, RSASignaturePadding.Pss);
    }
}

5.4 Deterministic pack builder

src/StellaOps.PatchSig/Packaging/SigPackBuilder.cs

using System.IO.Compression;
using StellaOps.PatchSig.Dsse;

namespace StellaOps.PatchSig.Packaging;

public static class SigPackBuilder
{
    private static readonly DateTimeOffset ZipTime = new(1980, 1, 1, 0, 0, 0, TimeSpan.Zero);

    public static void BuildFromDirectory(string inDir, string outZip)
    {
        var dsseFiles = Directory.EnumerateFiles(inDir, "*.dsse.json", SearchOption.AllDirectories)
                                 .OrderBy(p => p, StringComparer.Ordinal)
                                 .ToList();

        var entries = new List<SigPackIndexEntry>();
        var sigBlobs = new List<(string SigId, byte[] EnvBytes)>();

        foreach (var f in dsseFiles)
        {
            var envBytes = File.ReadAllBytes(f);
            var env = CanonicalJson.Deserialize<DsseEnvelope>(envBytes);

            var payloadBytes = Base64Url.Decode(env.Payload);
            var sigIdHex = SigId.ComputeSha256Hex(payloadBytes);
            var sigId = $"sha256:{sigIdHex}";

            // best-effort parse payload for index fields
            var payload = CanonicalJson.Deserialize<DeltaSigPayloadV1>(payloadBytes);

            var relPath = $"sigs/{sigId.Replace(':','-')}.dsse.json";
            entries.Add(new SigPackIndexEntry(
                SigId: sigId,
                Cve: payload.Cve,
                Package: payload.Package.Name,
                Soname: payload.Package.Soname,
                Arch: payload.Target.Arch,
                Abi: payload.Target.Abi,
                Path: relPath
            ));

            sigBlobs.Add((sigId, envBytes));
        }

        var index = new SigPackIndexV1(
            Schema: "stellaops.deltasigpack.v1",
            Entries: entries.OrderBy(e => e.SigId, StringComparer.Ordinal).ToList()
        );
        var indexBytes = CanonicalJson.Serialize(index);

        Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(outZip))!);
        if (File.Exists(outZip)) File.Delete(outZip);

        using var fs = File.Create(outZip);
        using var zip = new ZipArchive(fs, ZipArchiveMode.Create);

        AddEntry(zip, "index.json", indexBytes);

        foreach (var (sigId, bytes) in sigBlobs.OrderBy(x => x.SigId, StringComparer.Ordinal))
        {
            var rel = $"sigs/{sigId.Replace(':','-')}.dsse.json";
            AddEntry(zip, rel, bytes);
        }
    }

    private static void AddEntry(ZipArchive zip, string path, byte[] bytes)
    {
        var entry = zip.CreateEntry(path, CompressionLevel.Optimal);
        entry.LastWriteTime = ZipTime;
        using var s = entry.Open();
        s.Write(bytes, 0, bytes.Length);
    }
}

public sealed record SigPackIndexV1(string Schema, List<SigPackIndexEntry> Entries);

public sealed record SigPackIndexEntry(
    string SigId,
    string Cve,
    string Package,
    string Soname,
    string Arch,
    string Abi,
    string Path
);

6) Where ELF extraction plugs in (without contaminating the CLI contract)

You keep the CLI module clean by depending on an extractor interface that lives in scanner/webservice libraries, not in the CLI itself.

Example SPI (lives in StellaOps.PatchSig or StellaOps.Scanner.ElfDelta.Abstractions):

public interface IElfDeltaExtractor
{
    // Takes a skeleton payload and fills hashHex/sizeBytes/chunks/cfg for each symbol entry.
    DeltaSigPayloadV1 PopulateFromElf(DeltaSigPayloadV1 skeleton, string elfPath);
}

Then stella deltasig mk --elf /path/to/libfoo.so.1 ... can become a “fully automatic” authoring tool by resolving IElfDeltaExtractor from DI when present. If it isnt present, mk still creates a valid skeleton that can be filled by CI tooling.

This respects your architectural split: heavy analysis logic stays in scanner/webservice libraries; the CLI module remains a thin orchestration surface.


7) What you get immediately

  • A first-class stella deltasig module with working:

    • deterministic payload emission
    • DSSE signing and verification (ECDSA P-256 or RSA-PSS)
    • deterministic offline pack bundling
    • stable signature IDs
  • A clean seam to plug your ELF normalization/hasher implementation (the complex part) without redesigning CLI later.

If you want next, I can add the match command and define the exact evidence JSON object that scanner.webservice should emit into VEX (“Fixed by backport (verified)”) so the CLI and scanner share the same evidence type byte-for-byte.