# Pipeline bez sekretów: SPIFFE jako tożsamość zamiast hasła w CI

> Hasło nie ma tożsamości - dowodzi tylko, że ktoś zna hasło. SPIFFE zamienia to pytanie na inne: czy jesteś tym procesem, w tym podzie, na tym węźle. Use-case z pipeline'u eiac.dev, z uczciwym wskazaniem, gdzie „zero-secrets" się kończy.

URL: https://eiac.dev/blog/spiffe-zero-secrets-pipeline
Filar: Security-as-Code
Data: 2026-07-20
Tagi: use-case, spiffe, spire, ci-cd, security, cosign, vault, kubernetes

---

*Kolejny wpis z serii **use-case'ow**: konkretne wdrozenie zamiast teorii, z kodem i wnioskami. Reszta pod tagiem `use-case`.*

## Dwadzieścia sekretów i jedno pytanie, na które nie umiałem odpowiedzieć

Rutynowy przegląd konfiguracji Gitei. Wchodzę w ustawienia organizacji, zakładka Secrets, i patrzę na listę: `REGISTRY_PASSWORD`, `KUBECONFIG_PROD`, `ARGOCD_TOKEN`, `COSIGN_KEY`, `COSIGN_PASSWORD`, `VAULT_TOKEN`, `SONAR_TOKEN` i kilkanaście pomniejszych. Klasyka. Wszystko poukładane, wszystko opisane, nic nie leży w repo.

I wtedy przychodzi pytanie, które sam sobie zadaję i na które nie mam odpowiedzi: **kiedy ostatnio któryś z nich był rotowany?**

Nie wiem. Gitea nie pokazuje daty utworzenia sekretu. Wiem tyle, że `COSIGN_KEY` powstał, gdy zaczynaliśmy podpisywać obrazy, czyli gdzieś półtora roku temu. Że `REGISTRY_PASSWORD` to robot account z opcją Never Expire, bo tak było najprościej. Że `KUBECONFIG_PROD` zawiera token service accounta, który technicznie może wszystko w namespace, i leży w kilku miejscach naraz: w Gitei, w moim `~/.kube/config` i w jakimś starym wątku na Slacku, do którego bałem się zajrzeć.

Drugie pytanie jest gorsze: **kto tego użył wczoraj?** Log Gitei powie mi, że job się odpalił. Registry powie, że robot account wypchnął obraz. Ale czy to był mój pipeline, czy ktoś, kto wykradł hasło robota trzy miesiące temu i od tamtej pory grzecznie pushuje obrazy - tego nie wie nikt. Bo hasło nie ma tożsamości. Hasło to tylko dowód na to, że ktoś **zna hasło**.

To jest moment, w którym warto przestać myśleć o rotacji, a zacząć o tożsamości. Pisałem wcześniej o [Security Plane](/blog/security-plane-sekrety-tozsamosc-policy) i o tym, że tożsamość, sekrety i policy to trzy filary jednej konstrukcji. Ten tekst jest zejściem z poziomu wzorca na poziom komend - co konkretnie trzeba postawić, żeby ten pierwszy filar zaczął nieść pozostałe. I tu wchodzi [SPIFFE](https://spiffe.io/docs/latest/spiffe-about/overview/).

## Co SPIFFE właściwie proponuje

SPIFFE (Secure Production Identity Framework for Everyone) to nie narzędzie, tylko zestaw otwartych standardów. Projekt jest w CNCF na poziomie graduated, razem z referencyjną implementacją [SPIRE](https://spiffe.io/docs/latest/spire-about/). Pomysł ma trzy elementy i wszystkie trzy są proste.

**SPIFFE ID** to nazwa workloadu w formie URI: `spiffe://eiac.dev/ci/runner/builder`. Nie IP, nie hostname, nie login. Nazwa, która przeżywa restart poda i przeniesienie na inny węzeł.

**SVID** (SPIFFE Verifiable Identity Document) to kryptograficzny dokument potwierdzający ten ID. W dwóch wariantach: [X.509-SVID](https://spiffe.io/docs/latest/spiffe-specs/x509-svid/), czyli certyfikat z SPIFFE ID w polu SAN URI, oraz [JWT-SVID](https://spiffe.io/docs/latest/spiffe-specs/jwt-svid/), czyli token, w którym `sub` to SPIFFE ID. Domyślne czasy życia w SPIRE: **1 godzina dla X.509 i 5 minut dla JWT**. Rotacja leci automatycznie po połowie czasu życia, bez restartu workloadu.

**Workload API** to lokalny gniazdo uniksowe, z którego workload pobiera swój SVID - i o to chodzi w całej sprawie: **nie podaje przy tym żadnego credentiala**. Nie ma hasła do pobrania hasła. Zamiast tego agent SPIRE patrzy, kto właściwie puka do gniazda: jaki to proces, w jakim kontenerze, w jakim podzie, z jakim service accountem. To się nazywa **attestation** i jest sercem całej konstrukcji.

<figure>
<svg viewBox="0 0 640 300" role="img" aria-label="Łańcuch zaufania SPIFFE: SPIRE Server jest kotwicą zaufania i wystawia SVID-y. Agent na węźle udowadnia tożsamość węzła przez node attestation (k8s_psat lub x509pop). Workload puka do Workload API po gnieździe uniksowym bez żadnego credentiala, agent weryfikuje go przez workload attestation na podstawie selektorów pod-label, service account, uid procesu. W efekcie workload dostaje X.509-SVID i JWT-SVID.">
  <g font-family="'Poppins', system-ui, sans-serif" text-anchor="middle">
    <rect x="180" y="16" width="280" height="52" rx="6" fill="none" stroke="var(--color-rust)" stroke-width="1.5"/>
    <text x="320" y="40" fill="var(--color-rust)" font-size="13">SPIRE Server (trust domain: eiac.dev)</text>
    <text x="320" y="58" fill="var(--color-muted)" font-size="10" font-family="'JetBrains Mono Variable', monospace">kotwica zaufania + rejestr entries</text>
    <line x1="320" y1="68" x2="320" y2="100" stroke="var(--color-rust)" stroke-width="1.5"/>
    <path d="M320 100 l-4 -7 h8 z" fill="var(--color-rust)"/>
    <text x="320" y="88" fill="var(--color-muted)" font-size="10" font-family="'JetBrains Mono Variable', monospace" dx="130">node attestation</text>
    <rect x="120" y="106" width="400" height="52" rx="6" fill="none" stroke="currentColor" stroke-width="1.5"/>
    <text x="320" y="128" fill="currentColor" font-size="13">SPIRE Agent na węźle</text>
    <text x="320" y="146" fill="var(--color-muted)" font-size="10" font-family="'JetBrains Mono Variable', monospace">k8s_psat (pod) &#183; x509pop / tpm_devid (VM)</text>
    <line x1="320" y1="158" x2="320" y2="190" stroke="currentColor" stroke-width="1.5"/>
    <path d="M320 190 l-4 -7 h8 z" fill="currentColor"/>
    <text x="320" y="178" fill="var(--color-muted)" font-size="10" font-family="'JetBrains Mono Variable', monospace" dx="138">workload attestation</text>
    <rect x="120" y="196" width="400" height="56" rx="6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-dasharray="5 3"/>
    <text x="320" y="218" fill="currentColor" font-size="13">workload puka do Workload API (UDS, bez credentiala)</text>
    <text x="320" y="237" fill="var(--color-muted)" font-size="10" font-family="'JetBrains Mono Variable', monospace">selektory: k8s:sa &#183; k8s:pod-label &#183; unix:uid &#183; docker:label</text>
    <line x1="320" y1="252" x2="320" y2="278" stroke="var(--color-rust)" stroke-width="1.5"/>
    <path d="M320 278 l-4 -7 h8 z" fill="var(--color-rust)"/>
    <text x="320" y="296" fill="var(--color-rust)" font-size="12">X.509-SVID (1h) &#183; JWT-SVID (5m)</text>
  </g>
</svg>
<figcaption>Cała sztuczka polega na tym, że strzałki idą w dół, a nie w górę. Workload niczego nie przedstawia - to platforma poświadcza, kim on jest.</figcaption>
</figure>

Różnica wobec sekretu jest zasadnicza. Sekret odpowiada na pytanie „czy znasz hasło". SVID odpowiada na pytanie „czy jesteś tym procesem, w tym podzie, z tym service accountem, na tym węźle". Pierwszego da się ukraść i użyć gdziekolwiek. Drugiego nie, bo jest związany z miejscem, w którym powstał, i wygasa, zanim zdążysz go gdziekolwiek przenieść.

## Inwentarz: co konkretnie ma zniknąć

Zanim cokolwiek postawimy, robimy spis. To jest ten sam ruch, co przy [wdrażaniu LLM w legacy](/blog/llm-w-starym-projekcie-case-ledo) - najpierw wypisz, co masz, potem myśl o narzędziu. Poniżej mój realny pipeline eiac.dev z mapowaniem na mechanizm SPIFFE.

| Sekret dziś | Co robi | Czym zastępujemy | Trudność |
|---|---|---|---|
| `REGISTRY_PASSWORD` (robot, Never Expire) | push obrazu | JWT-SVID -> własny token server -> krótkożyciowy token OCI | średnia |
| `COSIGN_KEY` + `COSIGN_PASSWORD` | podpis obrazu | cosign keyless, JWT-SVID -> prywatne Fulcio | wysoka |
| `VAULT_TOKEN` / AppRole | sekrety aplikacyjne | X.509-SVID -> Vault auth `cert` po SAN URI | niska |
| `KUBECONFIG_PROD` | `kubectl apply` z CI | usuwamy przez GitOps: CI nie dotyka klastra | niska |
| `ARGOCD_TOKEN` | trigger synca | usuwamy: ArgoCD sam pilnuje repo | niska |

Ostatnie dwa wiersze są najważniejsze i najmniej efektowne. **Najtańszy sposób na wyeliminowanie sekretu to zlikwidowanie potrzeby jego istnienia.** Jeśli pipeline nie deployuje, tylko wypycha commit z nowym tagiem obrazu do repo gitops, a ArgoCD sam pilnuje stanu klastra, to `KUBECONFIG_PROD` przestaje być potrzebny bez żadnego SPIFFE. Zanim zaczniesz stawiać SPIRE, przejrzyj listę pod tym kątem. U mnie w ten sposób odpadły dwa sekrety z pięciu.

## Etap 1: runner dostaje tożsamość

Tu jest cała trudność i tu leży prawdziwy koszt wdrożenia. Wszystko dalsze jest łatwe, o ile ten etap wyjdzie.

Problem sprowadza się do pytania: skąd SPIRE ma wiedzieć, że proces, który puka do Workload API, to naprawdę runner budujący projekt X, a nie cokolwiek innego na tej samej maszynie. Odpowiedź zależy od tego, gdzie ten runner mieszka, i różnica jest jakościowa, nie kosmetyczna.

### Wariant A: runnery jako pody na Kubernetes

To ścieżka bez pisania kodu. Node attestation robi plugin [`k8s_psat`](https://github.com/spiffe/spire/blob/main/doc/plugin_server_nodeattestor_k8s_psat.md): agent SPIRE czyta projected service account token ze swojego poda, a serwer weryfikuje go przez TokenReview API klastra. Klucz nigdy nie opuszcza węzła, nie ma nic do wykradzenia.

Workload attestation robi plugin [`k8s`](https://github.com/spiffe/spire/blob/main/doc/plugin_agent_workloadattestor_k8s.md): agent wyciąga identyfikator poda z cgroup procesu i pyta kubelet, co to za pod. Wpis w rejestrze wygląda tak:

```bash
spire-server entry create \
  -parentID  spiffe://eiac.dev/spire/agent/k8s_psat/prod/<node-uid> \
  -spiffeID  spiffe://eiac.dev/ci/runner/builder \
  -selector  k8s:ns:gitea-runners \
  -selector  k8s:sa:act-runner \
  -selector  k8s:pod-label:eiac.dev/role:builder
```

Do poda runnera montujesz gniazdo przez [SPIFFE CSI Driver](https://github.com/spiffe/spiffe-csi), żeby nie potrzebować `hostPath`:

```yaml
volumes:
  - name: spiffe-workload-api
    csi:
      driver: csi.spiffe.io
      readOnly: true
```

Jedna pułapka warta zapamiętania: katalog z gniazdem po stronie DaemonSetu agenta **musi** być `hostPath`, nie `emptyDir`. Przy `emptyDir` restart poda agenta rozwala bind mounty we wszystkich workloadach i dostajesz cichą awarię, której nie widać w logach agenta.

### Wariant B: runnery na VM lub bare metal

Tu robi się ciekawiej, bo **oficjalnej ścieżki dla efemerycznych runnerów CI po prostu nie ma**. Sprawdziłem to, zanim zacząłem cokolwiek budować. Był konkretny wniosek o generyczny node attestor konsumujący JWT ([spire#5647](https://github.com/spiffe/spire/issues/5647)) - zamknięty w grudniu 2024 bez implementacji, z rekomendacją maintainerów, żeby użyć `x509pop`. Warto o tym wiedzieć, zanim spędzi się dzień na szukaniu pluginu, którego nie ma.

Realne opcje dla VM:

| Plugin | Mechanizm | Ocena |
|---|---|---|
| [`x509pop`](https://github.com/spiffe/spire/blob/main/doc/plugin_server_nodeattestor_x509pop.md) | cert z wewnętrznego PKI + challenge-response | najlepszy kompromis, klucz nie opuszcza maszyny |
| [`tpm_devid`](https://github.com/spiffe/spire/blob/main/doc/plugin_agent_nodeattestor_tpm_devid.md) | dowód rezydencji klucza w TPM 2.0 | najmocniejszy, wymaga provisioningu LDevID |
| `aws_iid` / `gcp_iit` | instance identity document z IMDS | wygodny w chmurze, ale TOFU: atestacja raz na węzeł |
| `join_token` | jednorazowy pre-shared key, domyślnie ważny 600s | **to nadal sekret**, tylko krótszy |

Ten ostatni wiersz to najczęstsza pułapka w tym temacie. `join_token` bywa reklamowany jako rozwiązanie dla efemerycznych runnerów, ale on niczego nie eliminuje - zamienia jeden długożyciowy bearer na krótkożyciowy bearer, który i tak trzeba jakoś dostarczyć do runnera. Jako bootstrap do PoC jest w porządku. Jako docelowa architektura to samooszukiwanie.

Uczciwy wniosek z porównania: **na k8s SPIFFE dowozi zero-secrets naprawdę, na VM dowozi je pod warunkiem, że masz wewnętrzne PKI albo TPM.** Jeśli nie masz ani jednego, ani drugiego, to zaczynasz nie od SPIRE, tylko od PKI - i to jest osobny projekt, nie krok w tym.

## Etap 2: push do registry bez hasła

Tu potrzebna jest uczciwość, bo w tym miejscu marketing zwykle rozjeżdża się z protokołem.

[Specyfikacja token auth dla OCI registry](https://distribution.github.io/distribution/spec/auth/token/) nie przyjmie JWT-SVID jako bearera. Registry ufa wyłącznie tokenom podpisanym kluczem zakotwiczonym w jego własnej konfiguracji ([`auth.token.rootcertbundle`](https://distribution.github.io/distribution/about/configuration/)) i mającym właściwe claimy `access`. SVID nie ma tej struktury i mieć jej nie będzie.

Harbor też nie pomoże wprost. Jego wsparcie OIDC dla `docker login` działa przez tak zwany CLI secret, bo - cytując [dokumentację](https://goharbor.io/docs/2.13.0/administration/configure-authentication/oidc-auth/) - „the Docker and Helm CLIs cannot handle redirection for OIDC". Z punktu widzenia Dockera to nadal hasło, tyle że mintowane przez Harbora. mTLS ze SPIFFE do API Harbora nie istnieje natywnie i nie znalazłem nawet feature requestu w tę stronę - to wniosek z braku dokumentacji, więc traktuj go jako „nie znalazłem", nie jako „nie da się".

Realna ścieżka jest inna i wynika z jednego zdania w specyfikacji: **token server jest twój, a sposób uwierzytelnienia klienta wobec niego spec zostawia poza zakresem.** Czyli:

```
runner --(JWT-SVID lub mTLS na X.509-SVID)--> własny token server
                                                    |
                             waliduje wobec JWKS SPIRE, mapuje SPIFFE ID na scope
                                                    |
                                        krótkożyciowy JWT podpisany kluczem registry
                                                    |
                                              docker push
```

I tu pada wniosek, który uważam za najważniejszy w całym temacie. **`docker push` na końcu i tak dostaje hasło.** Zawsze. Protokół OCI kończy się bearerem i nic tego nie zmieni. Różnica polega na tym, że to hasło jest **wyprowadzone z tożsamości workloadu i żyje kilkanaście minut**, zamiast leżeć w konfiguracji CI przez półtora roku.

„Zero-secrets" nie znaczy więc „bez credentiali". Znaczy **bez credentiali trwałych, przenośnych i niepowiązanych z tożsamością**. Kto obiecuje co innego, sprzedaje slajd, nie architekturę.

## Etap 3: podpisywanie obrazów tożsamością buildera

Ten etap jest najładniejszy koncepcyjnie, bo tu SPIFFE nie zastępuje sekretu innym mechanizmem - **on usuwa klucz całkowicie**.

Cosign od wersji 2.0 domyślnie robi keyless: generuje efemeryczną parę kluczy w pamięci, wymienia token OIDC na krótkożyciowy certyfikat w [Fulcio](https://github.com/sigstore/fulcio), podpisuje, wrzuca wpis do Rekora i **wyrzuca klucz prywatny**. Certyfikat żyje dokładnie 10 minut. Nie ma czego rotować, bo nie ma czego przechowywać.

Fulcio ma natywne wsparcie dla SPIFFE - typ issuera `spiffe`, który parsuje `sub` jako SPIFFE ID i osadza go w SAN URI certyfikatu. Cosign ma z drugiej strony wbudowanego providera SPIFFE, który sam woła Workload API. Cały krok w pipeline wygląda tak:

```bash
export SPIFFE_ENDPOINT_SOCKET=/run/spire/sockets/agent.sock

cosign sign \
  --oidc-provider spiffe \
  --fulcio-url https://fulcio.internal.eiac.dev \
  --rekor-url  https://rekor.internal.eiac.dev \
  --yes registry.eiac.dev/app@sha256:<digest>
```

Zero flag z kluczami. Zero zmiennych z hasłem. Weryfikacja po tożsamości buildera:

```bash
cosign verify \
  --certificate-identity     "spiffe://eiac.dev/ci/runner/builder" \
  --certificate-oidc-issuer  "https://oidc.internal.eiac.dev" \
  registry.eiac.dev/app@sha256:<digest>
```

Teraz twarde ograniczenie, które trzeba znać, zanim się to zaplanuje. **Publiczne Fulcio nie przyjmie twojego JWT-SVID.** Sprawdziłem konfigurację na żywym endpoincie `fulcio.sigstore.dev/api/v2/configuration`: nie ma tam ani jednego issuera typu `spiffe`, wszystkie mają puste `spiffeTrustDomain`. Publiczna instancja zna tylko dostawców typu `email`, `ci-provider` (GitHub Actions, GitLab, CircleCI, Buildkite), `chainguard-identity` oraz wildcardy typu `kubernetes` dla EKS/GKE/AKS. Prywatna trust domain wymaga rejestracji issuera przez PR do repo Fulcio i to nie jest realna ścieżka dla wewnętrznej domeny.

Konsekwencja: keyless ze SPIFFE oznacza **własny stos Sigstore**. Najprościej przez [sigstore/scaffolding](https://github.com/sigstore/scaffolding) - Fulcio, CT log, Rekor, TSA i wygenerowany root TUF. Jako issuera wystawiasz [SPIRE OIDC Discovery Provider](https://github.com/spiffe/spire/blob/main/support/oidc-discovery-provider/README.md), który serwuje JWKS pod `/keys` (nie pod `/.well-known/jwks.json` - to jest drobiazg, na którym łatwo stracić godzinę).

Do tego trzeba dopisać uczciwie: **to jest kilka usług do utrzymania**. Dla jednego repo nie ma sensu. Dla platformy z kilkudziesięcioma serwisami i wymogiem SLSA - ma.

## Etap 4: sekrety aplikacyjne

To najprostszy etap i dobre miejsce, żeby zacząć wdrożenie, bo daje efekt przy najmniejszym nakładzie.

Vault ma auth method [`cert`](https://developer.hashicorp.com/vault/api-docs/auth/cert), który działa ze SVID-em wprost. Jako CA podajesz bundle SPIRE, a dopasowanie robisz po SAN URI:

```bash
spire-server bundle show -format pem > bundle.pem

vault write auth/cert/certs/ci-builder \
    certificate=@bundle.pem \
    allowed_uri_sans="spiffe://eiac.dev/ci/runner/*" \
    token_policies="ci-read" \
    token_ttl=15m token_type=batch
```

Trzy rzeczy, na które trzeba uważać, bo każda z nich to cicha dziura w bezpieczeństwie:

**Pusty `allowed_uri_sans` wpuszcza wszystko.** Kod Vaulta w takim wypadku zwraca `true` bez sprawdzania czegokolwiek. W połączeniu z bundlem SPIRE jako CA oznacza to, że **każdy** workload w trust domain dostaje polityki tej roli. To najbardziej prawdopodobna misconfiguracja w całym wzorcu.

**Gwiazdka łapie także ukośnik.** Vault używa tu biblioteki go-glob, więc `spiffe://eiac.dev/ci/*` matchuje też `spiffe://eiac.dev/ci/a/b/c`. Nie ma wildcarda ograniczonego do jednego segmentu ścieżki. Projektuj przestrzeń nazw SPIFFE tak, żeby ta gwiazdka nie bolała.

**Odnowienie tokenu nie zadziała po rotacji SVID-a.** Vault przy renew porównuje `subject_key_id` certyfikatu z tym z logowania. Po rotacji (mniej więcej co pół godziny, bo SVID żyje godzinę, a SPIRE rotuje po połowie życia z niewielkim jitterem) para kluczy jest nowa i renew failuje. Poprawna odpowiedź to `token_type=batch`, `token_ttl` krótszy niż TTL SVID-a i traktowanie każdej rotacji jak świeżego logowania. **Nie** `disable_binding=true`, choć to pierwsza rzecz, którą podpowie wyszukiwarka.

Alternatywą jest [SPIKE](https://github.com/spiffe/spike) - store sekretów zbudowany od zera na SPIFFE, bez unseal keys, z root key wyłącznie w pamięci i shardami Shamira w komponentach Keeper. Ładna architektura, ale README mówi wprost: dojrzałość „Development", „use in critical systems is not advised at this time". Ostatnie wydanie v0.8.0 z listopada 2025 miało breaking changes w API. Do laba tak, do produkcji jeszcze nie.

## Etap 5: deploy, czyli sekret, który po prostu kasujemy

Najlepszy sekret to ten, którego nie ma. Pipeline nie dostaje dostępu do klastra w ogóle - kończy się na wypchnięciu commita z nowym digestem obrazu do repo gitops, a resztę robi ArgoCD, który już jest w klastrze i nie potrzebuje żadnego tokenu z zewnątrz.

Domknięciem jest polityka admisji, która sprawdza, czy obraz został podpisany przez właściwą tożsamość. Wtedy nawet przejęty runner niewiele zdziała, bo jego SPIFFE ID nie przejdzie weryfikacji:

```yaml
apiVersion: policy.sigstore.dev/v1beta1
kind: ClusterImagePolicy
metadata:
  name: require-builder-svid
spec:
  mode: enforce
  images:
    - glob: "registry.eiac.dev/apps/**"
  authorities:
    - name: spire-builder
      keyless:
        trustRootRef: eiac-sigstore
        identities:
          - issuer: https://oidc.internal.eiac.dev
            subject: spiffe://eiac.dev/ci/runner/builder
```

To działa, bo cosign przy dopasowywaniu identity zbiera wszystkie SAN-y certyfikatu włącznie z URI - a SPIFFE ID siedzi właśnie w URI SAN. Alternatywnie to samo da się zrobić przez [Kyverno verifyImages](https://kyverno.io/docs/policy-types/cluster-policy/verify-images/sigstore/), z tym że przy prywatnym Fulcio bez Rekora Kyverno **wymaga** podania `roots` - inaczej webhook odrzuci politykę.

I tu domyka się ładna pętla: SPIRE potrafi też użyć podpisu jako selektora przy workload attestation (`k8s:image-signature-subject`, dostępny po włączeniu bloku `sigstore` na atestorze), czyli podpis z etapu 3 może być warunkiem wydania tożsamości aplikacji w etapie 1. Tożsamość poświadcza podpis, podpis poświadcza tożsamość.

<figure>
<svg viewBox="0 0 640 340" role="img" aria-label="Pipeline zero-secrets: runner z tożsamością SPIFFE wykonuje cztery kroki. Krok jeden: JWT-SVID wymieniany w token serverze na krótkożyciowy token do pushu obrazu do registry. Krok dwa: cosign sign z providerem spiffe wymienia JWT-SVID w prywatnym Fulcio na certyfikat ważny 10 minut. Krok trzy: X.509-SVID służy do logowania do Vaulta po SAN URI. Krok cztery: commit z nowym digestem do repo gitops, skąd ArgoCD deployuje na klaster, gdzie polityka admisji weryfikuje podpis wobec SPIFFE ID buildera. Żaden krok nie używa sekretu z konfiguracji CI.">
  <g font-family="'Poppins', system-ui, sans-serif" text-anchor="middle">
    <rect x="180" y="14" width="280" height="48" rx="6" fill="none" stroke="var(--color-rust)" stroke-width="1.5"/>
    <text x="320" y="36" fill="var(--color-rust)" font-size="13">runner: spiffe://eiac.dev/ci/runner/builder</text>
    <text x="320" y="53" fill="var(--color-muted)" font-size="10" font-family="'JetBrains Mono Variable', monospace">zero sekretow w konfiguracji CI</text>
    <line x1="320" y1="62" x2="320" y2="84" stroke="var(--color-rust)" stroke-width="1.5"/>
    <path d="M320 84 l-4 -7 h8 z" fill="var(--color-rust)"/>
    <rect x="16" y="92" width="196" height="62" rx="6" fill="none" stroke="currentColor" stroke-width="1.5"/>
    <text x="114" y="114" fill="currentColor" font-size="12">1. push obrazu</text>
    <text x="114" y="131" fill="var(--color-muted)" font-size="9" font-family="'JetBrains Mono Variable', monospace">JWT-SVID &#8594; token server</text>
    <text x="114" y="145" fill="var(--color-muted)" font-size="9" font-family="'JetBrains Mono Variable', monospace">&#8594; token OCI na 15 min</text>
    <rect x="222" y="92" width="196" height="62" rx="6" fill="none" stroke="currentColor" stroke-width="1.5"/>
    <text x="320" y="114" fill="currentColor" font-size="12">2. podpis</text>
    <text x="320" y="131" fill="var(--color-muted)" font-size="9" font-family="'JetBrains Mono Variable', monospace">cosign --oidc-provider spiffe</text>
    <text x="320" y="145" fill="var(--color-muted)" font-size="9" font-family="'JetBrains Mono Variable', monospace">&#8594; cert Fulcio na 10 min</text>
    <rect x="428" y="92" width="196" height="62" rx="6" fill="none" stroke="currentColor" stroke-width="1.5"/>
    <text x="526" y="114" fill="currentColor" font-size="12">3. sekrety aplikacji</text>
    <text x="526" y="131" fill="var(--color-muted)" font-size="9" font-family="'JetBrains Mono Variable', monospace">X.509-SVID &#8594; Vault cert</text>
    <text x="526" y="145" fill="var(--color-muted)" font-size="9" font-family="'JetBrains Mono Variable', monospace">match po SAN URI</text>
    <line x1="320" y1="154" x2="320" y2="182" stroke="currentColor" stroke-width="1.5"/>
    <path d="M320 182 l-4 -7 h8 z" fill="currentColor"/>
    <rect x="140" y="188" width="360" height="48" rx="6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-dasharray="5 3"/>
    <text x="320" y="210" fill="currentColor" font-size="12">4. commit digestu do repo gitops</text>
    <text x="320" y="227" fill="var(--color-muted)" font-size="9" font-family="'JetBrains Mono Variable', monospace">CI nie ma dostepu do klastra: KUBECONFIG i ARGOCD_TOKEN znikaja</text>
    <line x1="320" y1="236" x2="320" y2="262" stroke="currentColor" stroke-width="1.5"/>
    <path d="M320 262 l-4 -7 h8 z" fill="currentColor"/>
    <rect x="140" y="268" width="360" height="48" rx="6" fill="none" stroke="var(--color-rust)" stroke-width="1.5"/>
    <text x="320" y="290" fill="var(--color-rust)" font-size="12">ArgoCD &#8594; klaster &#8594; ClusterImagePolicy</text>
    <text x="320" y="307" fill="var(--color-muted)" font-size="9" font-family="'JetBrains Mono Variable', monospace">wpuszcza tylko obrazy podpisane przez SPIFFE ID buildera</text>
    <text x="320" y="334" fill="var(--color-muted)" font-size="10">tozsamosc poswiadcza podpis, podpis poswiadcza tozsamosc</text>
  </g>
</svg>
<figcaption>Cztery kroki, żaden nie sięga do konfiguracji sekretów CI. Najdłuższy żyjący credential w tym obrazku to token OCI: piętnaście minut.</figcaption>
</figure>

## Wnioski

**1. Zero-secrets to skrót myślowy, nie stan.** Credentiale nie znikają - one przestają być trwałe, przenośne i anonimowe. Token do registry nadal istnieje, tylko żyje kwadrans i da się powiedzieć, który pod go dostał. To jest cała różnica i ona jest wystarczająco duża, żeby warto było się ruszyć.

**2. Punkt ciężkości przesuwa się na attestation.** Przestajesz pilnować dwudziestu sekretów, zaczynasz pilnować jednego pytania: skąd platforma wie, kim jest ten proces. To lepszy problem, bo ma jedną odpowiedź zamiast dwudziestu, ale nie jest to problem darmowy.

**3. Kubernetes zmienia wszystko.** Na k8s ścieżka jest gotowa: `k8s_psat` plus workload attestation po service accountcie plus CSI driver. Na VM oficjalnej ścieżki dla efemerycznych runnerów nie ma i nie będzie - wniosek o JWT node attestor zamknięto bez implementacji. Zostaje `x509pop` z wewnętrznym PKI albo `tpm_devid`, czyli trzeba mieć fundament, którego wiele zespołów nie ma.

**4. `join_token` to pułapka.** Wygląda jak rozwiązanie dla efemerycznych runnerów, a jest krótkożyciowym bearerem, który trzeba dostarczyć z zewnątrz. Do bootstrapu tak, jako architektura docelowa - nie.

**5. Kolejność wdrożenia jest odwrotna do atrakcyjności.** Najefektowniejszy jest keyless signing, a jest najdroższy: wymaga własnego stosu Sigstore, bo publiczne Fulcio nie zna twojej trust domain. Zacznij od skasowania sekretów, których nie potrzebujesz (GitOps zamiast `kubectl` z CI), potem Vault po SVID, dopiero na końcu registry i podpisy.

**6. Największe ryzyko to nie SPIRE, tylko konfiguracja po drugiej stronie.** Pusty `allowed_uri_sans` w Vaulcie wpuszcza całą trust domain. Gwiazdka w go-globie łapie ukośniki. Renew tokenu failuje po rotacji SVID-a. Wszystkie trzy to ciche awarie, które nie krzyczą w logach - i wszystkie trzy trzeba przetestować, zanim to wejdzie na produkcję.

**7. Wersje trzeba przypiąć.** Cosign v3 przeniósł adresy usług do konfiguracji dystrybuowanej przez TUF, a flagi `--fulcio-url` i `--rekor-url` zostały zdeprecjonowane i ukryte - formalnie nadal działają, ale zniknęły z pomocy i z wygenerowanej dokumentacji. Materiał w sieci opisuje głównie v2. Przypnij wersję w pipeline i sprawdź `--help` na swoim buildzie.

Praktyczna puenta: SPIFFE nie jest projektem na tydzień i nie zwraca się przy jednym repo. Zwraca się wtedy, gdy masz platformę, wiele zespołów i pytanie „kto tego użył wczoraj" zaczyna być pytaniem audytowym, a nie retorycznym. Do tego momentu wystarczy dobry GitOps i higiena sekretów. Ale warto wiedzieć, dokąd się idzie, zanim lista w zakładce Secrets urośnie do pięćdziesięciu pozycji.

Następny krok u mnie: lab `spiffe-zero-secrets`, w którym cała ta konstrukcja stoi na kind i da się ją odpalić jedną komendą. Plan wykonawczy jest już rozpisany; wyniki, razem z tym, co się nie udało, opiszę osobno.