stacklok / stacklok/mecatl

mecatl as its own SPIFFE issuer: trust domain, signing path, bundle publication

Open
#478 0 comments 0 reactions 1 assignee View on GitHub

@jhrozek is already working on this.

Since Sep 7, 2026.

Dominant language
Go
Stars
152
Forks
16
Avg merge
14h 48m
Merged PRs (30d)
536

Description

Scope is the shared infrastructure only — trust domain, signing path, bundle publication. The claim
vocabulary lands with the FS grant service, per the split in #377 ("the signing infrastructure, the
bundle endpoint and the trust domain are shared, and only the claim set differs").

mecatl. First slice of the issuer decision in #377. Gated by #375, which has now answered —
unfavourably for any deployment where a shell is reachable.

Hazards

Facts that were expensive to find and that nobody re-derives on the way to implementing. Each is
verified against code, and the first is verified against a running deployment.

  • #375 came back unfavourable, and this issue's payoff is conditional on that answer. #377
    sequenced this behind #375 because "if the signing key stays reachable from a shell the model drives
    then verifying against it is trusting the harness again." Verified against binaries built from this
    repo, in kind, driven through mecatl's own tool surface at --posture=auto: the model reads
    mecated's process environment via Bash{cat /proc/1/environ} (PID 1 is mecated; envscrub strips
    the child's env only); reads mecated's memory via dd if=/proc/1/mem with
    CapEff: 0000000000000000 on a kernel where /proc/sys/kernel/yama/ptrace_scope does not exist;
    and where a Workload API socket is mounted, obtains mecatl's own SVID and its private key with
    spire-agent api fetch x509 -write. So an in-process issuer's day-one payoff — a verifier checking
    that a narrowing happened rather than trusting the harness — does not exist while a shell is
    reachable. Full answers and evidence tiering on #375.
  • The pseudo-filesystem guard does not cover Bash, by construction.
    internal/app/escapeclassifier.go's classify switches on Read/Write/Edit and returns
    escapeInRoot for everything else. Read{/proc/1/environ} is refused with "never relaxed at any
    posture"; the same path through Bash is served. The FS tools have os.Root plus the guard; the
    command runner has a working directory and a scrubbed environment.
  • The command runner has no OS isolation at all. No chroot, unshare, seccomp,
    SysProcAttr or Credential anywhere in internal/adapter/osfs. buildSandboxedCommandRunner's
    "sandbox" is a git worktree: it contains writes to the repo, not reads of the host. Anyone reading
    the name could reasonably assume otherwise.
  • Shell-lessness is currently an accident of a base image. --no-bash defaults to false and
    --shell to /bin/sh in every binary. cmd/mecak8s is safe today only because its distroless
    image has no /bin/sh — verified: it reports unknown tool "Bash". A base-image change re-opens
    all three channels with no flag flipped and no test failing. --no-bash and --shell="" each
    remove the tool entirely, verified with a positive control proving the harness still functions.
  • A process-level SPIRE selector closes the Workload API route, and the chart default undoes it.
    Adding unix:path:/usr/local/bin/mecated to the entry closed it — verified: the shell then received
    only the ambient identity, not mecatl's. But the SPIRE Helm chart's default ClusterSPIFFEID uses
    pod-level selectors (k8s:ns, k8s:pod-label) and issues every pod in the namespace a
    ServiceAccount-scoped SVID regardless. Selector hardening has to cover entries nobody on this team
    created, and any verifier authorising on trust-domain membership rather than a specific ID is
    reachable without an issuer at all.
  • The trust-domain name is a one-way door. Per #377: --identity-domain is required when identity
    is enabled and validated against the §2.2 grammar (lowercase, [a-z0-9.\-_], at most 255 bytes, no
    userinfo or port, no percent-encoding). Never derive it from a bundle-endpoint hostname — the
    federation spec gives a concrete subdomain-squatting attack. One domain per deployment; path
    prefixes inside one shared domain cannot give key separation because the same key signs both.
  • The bundle needs one in-house verifier before it needs distribution. #377's open item: the grant
    issuer is in-process with the harness but the FS service is a separate process, so a static public
    key satisfies it until you want rotation without redeploying verifiers, or a second consumer.
  • Holder binding has never run. No cnf, no proof-of-possession, never exercised in any spike. It
    is half of what makes a stolen credential useless, and it belongs to the claim-vocabulary slice
    rather than this one — recorded here so the split does not lose it.

Acceptance criteria

  • Signing material is never held in a process that can spawn a shell. Enforced at startup, not
    documented as guidance: either the issuer is off, or Bash is absent. Never both present.
  • --identity-domain is required when identity is enabled and validated against the spec grammar.
    A name derived from a bundle-endpoint hostname is refused.
  • The signing intermediate is KMS-rooted, held in memory only, and never written to disk —
    including on crash and degrade paths.
  • Registration pins the workload by binary content (unix:sha256), not by path alone, and the
    deployment asserts that no ambient trust-domain-wide entry grants the agent pod an SVID.
  • The bundle is published with a documented verifier story for the FS grant service, and rotation
    states its overlap window rather than leaving it implicit.
  • With identity disabled, behaviour and stored bytes are unchanged from today, to the byte.

Proof

The same properties, adversarially, with an actor and an ordering. A property-level criterion can go
green while being false; these are what stop that.

  • With a shell reachable, startup refuses to hold signing material, and the refusal names the
    reason rather than failing obscurely.
  • With no shell, an injected agent driving every tool in the catalog obtains no SVID, no key and
    no bundle-signing material. Both halves tested — a positive control proving the tools work — so
    "the agent found nothing" cannot pass for "there was nothing to find".
  • A model-spawned process cannot obtain the agent's SVID from the Workload API, with the ambient
    ClusterSPIFFEID present exactly as the chart ships it.
  • A rotation happens while a parked run resumes across it, and the residual validity window is
    measured rather than assumed.
  • A deployment with identity disabled produces byte-identical stored snapshots to one built before
    this shipped.

Slices

Decomposition is the implementer's job; these are the natural seams, not a mandate.

  • The domain flag and its validation.
  • The KMS-rooted signing path, in-memory only.
  • Bundle publication and the FS grant service's verifier.
  • The startup refusal, and the test that fails if it regresses.

Reasoning

  • #377 — the index, where this decision was un-deferred and sequenced behind #375.
  • #375 — the spike that gated it, and the evidence for hazard 1.
  • #374 — the snapshot MAC key inherits the identical custody problem; the startup-refusal criterion
    there and here are the same mechanism.
  • docs/agent-identity-model.md
    and docs/agent-identity-outbound.md
    — the design this comes from.
  • docs/scoped-resource-grants.md
    — the consumer that un-deferred this, and whose v1 trust assumption ("the harness and the grant
    issuer are trusted; in v1 they are the same process") is the configuration hazard 1 falsifies.

What this issue deliberately does not answer

  • The claim vocabulary: instance and child SVID claim sets, authorization_details, the delegation
    chain. Lands with the FS grant service.
  • Holder binding (cnf). Never implemented; see hazards.
  • Whether a broker is needed. That decision turns on where key material lives once the startup
    refusal above forces the question, and is tracked on #372.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.