Lesson 11 of 15 · 45 min
Certificates That Live Sixty Seconds
Build a small workload API that identifies a process by asking the kernel who it is, then hands it a certificate valid for one minute — no password, no bootstrap secret, nothing on disk.
Learning objectives
- Explain how a short-lived credential reduces, but does not remove, the need for revocation.
- Describe the secret zero problem and how attestation avoids it.
- Read a SPIFFE ID and say what an SVID is.
- Build a workload API that attests a caller with SO_PEERCRED and issues a sixty-second certificate.
The problem you have been carrying since Lesson 5
Look at the certificate you generated:
Ten years. Sitting in a folder. Which means:
- if it leaks, it stays valid until 2036 and nobody will notice;
- cancelling it early needs extra infrastructure — a revocation list or an OCSP responder — that almost nobody actually runs correctly;
- replacing it is a manual job, so it gets postponed forever.
The instinct is "guard the file better". That is the wrong instinct, because it accepts the ten years as fixed and tries to compensate.
The actual fix
The idea of this chapter
Make certificates expire before they can matter.
A certificate that lives sixty seconds mostly cancels itself. Steal one and you have under a minute to open a connection with it. Leak one into a log and it is useless before anyone reads the log.
That removes the operational need for a revocation system in the common case, which is a large practical win. Be careful with the stronger version of the claim, though, because two things survive expiry:
- sixty seconds is plenty of time for a high-impact operation, if the attacker knows what they want;
- a connection already authenticated can outlive the certificate that authenticated it, which Lesson 13 demonstrates and which is the part people miss.
So the honest statement is that short lifetimes make revocation unnecessary for most purposes and cheap to skip, not that revocation is meaningless. You still want a way to remove a trust anchor and to stop issuing to a workload, and you still need to bound connection age.
But sixty-second certificates only work if something issues and replaces them automatically. Nobody runs gen-certs.sh every minute. That something is what you build in this lesson.
Platform requirement
The workload API in this chapter uses SO_PEERCRED, a Linux socket option, and reads /proc. Lessons 11 to 14 therefore need Linux or WSL. Everything up to Lesson 10 runs anywhere. macOS has an equivalent (LOCAL_PEERCRED / getpeereid) that reports the peer's user id but not its process id, so the command-line attestation below would have to be replaced.
SPIFFE in two ideas
SPIFFE is a standard for giving programs an identity. SPIRE is its best-known implementation. You are about to build a very small stand-in for the SPIRE agent.
Idea 1: an identity is a URI, not a hostname
spiffe://demo.local/service/go
▲ ▲ ▲
scheme trust domain name you chooseNotice it says nothing about where the service runs. That is deliberate. Containers move, scale up, and get rescheduled onto other machines. Identity should not change just because an IP address did.
This is also why subjectAltName=DNS:localhost,IP:127.0.0.1 from Lesson 5 was always a slightly awkward fit: it identified a location, not a program.
Idea 2: a certificate carrying that URI is called an X.509-SVID
SVID stands for SPIFFE Verifiable Identity Document. Nobody says the long version.
SPIFFE defines two kinds. An X.509-SVID is a certificate, and that is the one this course builds. A JWT-SVID carries the same identity as a signed token instead, for places where you cannot use client certificates. When this course says SVID it means the X.509 kind.
Here is the reassuring part: an SVID is not a new kind of file. It is an ordinary X.509 certificate, exactly like the ones in Lesson 5, with the SPIFFE URI stored in the same subjectAltName extension you already met — as a URI: entry instead of a DNS: entry.
| Compared with your Lesson 5 certificate | SVID |
|---|---|
| File format | Identical. Same X.509, same PEM |
| Signed by a CA | Yes, same as before |
| Proves possession by signing the handshake | Yes, same as before |
| Contains a name | Yes — a URI instead of a common name |
| Contains a hostname or IP | Not the ones we issue. This is the one real difference |
| Lifetime | 60 seconds instead of 10 years |
| Stored on disk | No. Held in memory only |
Only the last three rows are new. Everything you learned about certificates still applies.
The puzzle SPIFFE really solves: secret zero
Think carefully about this loop:
- to get a certificate, you must prove who you are;
- to prove who you are, you need a credential;
- that credential must also be delivered somehow, protected by another credential.
It is turtles all the way down. The first secret in the chain is called secret zero. It is where a large number of real breaches begin, because it ends up in an environment variable or a configuration file that far too many people can read.
SPIFFE breaks the loop by asking the operating system instead.
your service workload API (the agent)
│ │
│─── connects to a unix socket ──────►│
│ sends NOTHING: │
│ no key, no password, no token │
│ ├─ asks the KERNEL:
│ │ "which process is this?"
│ │ → pid 12345, uid 1000
│ │
│ ├─ looks up pid 12345:
│ │ what is it running?
│ │
│◄── certificate + private key ───────┤─ matches a registration entry
│ + trust bundle, valid 60s │ → spiffe://demo.local/service/goThe workload sends nothing and still receives a credential. It is identified by what it is, not by a secret it holds. There is no first secret, so there is nothing to leak.
In simple words
A password is a guard asking "what is the secret word?", which anyone who overheard it can answer. Attestation is a guard who does not ask anything, looks at which door you came through and what uniform you are wearing, and checks that against a list. You cannot overhear your way into being someone else.
Three words you will meet everywhere
| Word | Means |
|---|---|
| Attestation | Working out what a process actually is |
| Selector | A rule describing a process, such as unix:uid:1000 or k8s:sa:orders |
| Registration entry | A mapping from selectors to a SPIFFE ID |
Build the workload API
Create workload-api/main.go. This is a third package main, run with go run ./workload-api, exactly like the auth server.
Nothing else in your repository changes in this lesson. Your services keep using certs/ until Lesson 13.
Create the CA in memory
func newCA() (*ca, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, err
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "workload-api-ca"},
NotBefore: time.Now().Add(-time.Minute),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
IsCA: true,
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
return nil, err
}
cert, err := x509.ParseCertificate(der)
if err != nil {
return nil, err
}
return &ca{
key: key,
cert: cert,
bundle: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})),
}, nil
}Two things to see here.
Nothing is written to disk. When this process stops, the CA is gone and every certificate it issued expires within a minute. Compare that with ca.key, the most dangerous file in your repository.
The repeated argument. template is passed twice, because the CA signs its own certificate: the same object is both the certificate being created and the issuer signing it. That is what "self-signed root" means, and you saw its effect in Lesson 5 when subject and issuer matched.
Ask the kernel who is connecting
This function is the heart of the lesson:
func attest(conn *net.UnixConn) (pid int32, uid uint32, cmdline string, err error) {
raw, err := conn.SyscallConn()
if err != nil {
return 0, 0, "", err
}
var creds *syscall.Ucred
var credErr error
if err := raw.Control(func(fd uintptr) {
creds, credErr = syscall.GetsockoptUcred(int(fd), syscall.SOL_SOCKET, syscall.SO_PEERCRED)
}); err != nil {
return 0, 0, "", err
}
if credErr != nil {
return 0, 0, "", credErr
}
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", creds.Pid))
if err != nil {
return creds.Pid, creds.Uid, "", err
}
// /proc stores arguments separated by NUL bytes.
return creds.Pid, creds.Uid, strings.ReplaceAll(string(data), "\x00", " "), nil
}SO_PEERCRED is the important part. It asks the operating system: which process is on the other end of this socket? The OS answers with a process id and a user id.
Why the process cannot lie
The pid and uid come from the kernel's own record of who opened the socket, not from anything the connecting program sent. There is no field in the message to forge, because there is no message.
Then /proc/<pid>/cmdline tells us what that process is running.
A unix socket is a connection identified by a path on the filesystem rather than by a host and port. Programs read and write it exactly like a network connection, but only processes on the same machine can open it.
Why a unix socket and not a TCP port?
SO_PEERCRED only exists for local sockets. Over TCP there is no "which process", because the connection could come from any machine anywhere. Being local is exactly what makes attestation possible.
The registration table
type entry struct {
cmdlineContains string
spiffeID string
}
// Order matters: the first match wins. The node process's command line can
// contain BOTH "server.ts" and the project folder name, so "server.ts" has to
// be checked first.
var entries = []entry{
{"server.ts", "spiffe://" + trustDomain + "/service/typescript"},
{"authserver", "spiffe://" + trustDomain + "/service/auth"},
{"secure-services", "spiffe://" + trustDomain + "/service/go"},
}Those three strings come from how each program is started:
| Command | Command line the kernel reports contains |
|---|---|
node server.ts | server.ts |
go run ./authserver | authserver |
go run . | secure-services, the folder name |
go run compiles to a temporary binary named after the package directory, which is why the third entry matches your project folder.
How real SPIRE differs
Matching one substring of a command line is weak: any process whose command line happens to contain authserver would be issued that identity. Real SPIRE requires a set of selectors to match at once — user id, binary path, Kubernetes service account, container label — which removes both the weakness and the ordering problem. We use one readable rule so the mechanism stays visible.
Issue an SVID
func (c *ca) issue(spiffeID string) (*svid, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, err
}
uri, err := url.Parse(spiffeID)
if err != nil {
return nil, err
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, err
}
template := &x509.Certificate{
SerialNumber: serial,
NotBefore: time.Now().Add(-30 * time.Second), // allow for clock skew
NotAfter: time.Now().Add(svidTTL), // 60 seconds
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{
x509.ExtKeyUsageServerAuth,
x509.ExtKeyUsageClientAuth,
},
URIs: []*url.URL{uri}, // <- the identity lives here
BasicConstraintsValid: true,
}
der, err := x509.CreateCertificate(rand.Reader, template, c.cert, &key.PublicKey, c.key)
if err != nil {
return nil, err
}
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
return nil, err
}
return &svid{
SpiffeID: spiffeID,
Cert: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})),
Key: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})),
Bundle: c.bundle,
TTLSeconds: int(svidTTL.Seconds()),
}, nil
}Look hard at what is missing: no Subject, no DNSNames, no IPAddresses.
A SPIFFE certificate identifies a workload, not a host. That will break normal TLS verification, which checks the hostname, and Lesson 12 deals with it.
Also note the fresh key pair on every issue. Each issued leaf is valid for sixty seconds; when the services add rotation in Lesson 13, they request a new leaf and private key every twenty seconds so they renew well before expiry. The workload API's CA key is longer-lived, and the old service keys still remain under certs/ until Lesson 13; the claim here is specifically that the newly issued leaf credential is short-lived and replaced rather than reused.
The NotBefore is thirty seconds in the past. Clocks between machines are never perfectly aligned, and a certificate that is not valid yet fails just as hard as one that has expired.
Serve it
func (c *ca) handle(conn *net.UnixConn) {
defer conn.Close()
pid, uid, cmdline, err := attest(conn)
if err != nil {
log.Printf(" attestation failed: %v", err)
return
}
for _, e := range entries {
if !strings.Contains(cmdline, e.cmdlineContains) {
continue
}
s, err := c.issue(e.spiffeID)
if err != nil {
log.Printf(" issue failed: %v", err)
return
}
log.Printf(" issued %-38s pid=%d uid=%d ttl=%s", s.SpiffeID, pid, uid, svidTTL)
_ = json.NewEncoder(conn).Encode(s)
return
}
log.Printf(" no registration entry matches pid=%d (%s)", pid, strings.TrimSpace(cmdline))
}A process that matches nothing gets no answer and no explanation. That is the correct behavior: the caller learns nothing about which identities exist.
The complete workload API
// A tiny stand-in for the SPIRE agent.
//
// Real SPIRE is a pair of daemons and a gRPC API. This does the same four
// things in a form you can read in one sitting:
//
// 1. It holds the CA. Nothing else has a key on disk.
// 2. It listens on a UNIX SOCKET, not a TCP port.
// 3. When a workload connects, it asks the KERNEL who that process is.
// The workload does not get to say. This is called ATTESTATION.
// 4. It issues a certificate that lives 60 seconds, named with a SPIFFE ID.
//
// Linux only: SO_PEERCRED and /proc are Linux features.
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"fmt"
"log"
"math/big"
"net"
"net/url"
"os"
"strings"
"syscall"
"time"
)
const (
socketPath = "/tmp/spire-lite.sock"
trustDomain = "demo.local"
// Sixty seconds. Long enough to be usable, short enough that you can watch
// it rotate while reading the logs.
svidTTL = 60 * time.Second
)
// A registration entry maps "a process that looks like THIS" to a SPIFFE ID.
type entry struct {
cmdlineContains string
spiffeID string
}
// Order matters: the first match wins. The node process's command line can
// contain BOTH "server.ts" and the project folder name, so "server.ts" has to
// be checked first.
var entries = []entry{
{"server.ts", "spiffe://" + trustDomain + "/service/typescript"},
{"authserver", "spiffe://" + trustDomain + "/service/auth"},
{"secure-services", "spiffe://" + trustDomain + "/service/go"},
}
type svid struct {
SpiffeID string `json:"spiffe_id"`
Cert string `json:"cert"`
Key string `json:"key"`
Bundle string `json:"bundle"`
TTLSeconds int `json:"ttl_seconds"`
}
type ca struct {
key *ecdsa.PrivateKey
cert *x509.Certificate
bundle string // the CA certificate as PEM, for workloads to trust
}
// newCA creates the trust root in memory, at startup.
func newCA() (*ca, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, err
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "workload-api-ca"},
NotBefore: time.Now().Add(-time.Minute),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
IsCA: true,
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
return nil, err
}
cert, err := x509.ParseCertificate(der)
if err != nil {
return nil, err
}
return &ca{
key: key,
cert: cert,
bundle: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})),
}, nil
}
// issue mints one short-lived SVID for the given SPIFFE ID.
//
// Look at what is NOT in this certificate: no CommonName, no DNS name, no IP
// address. A SPIFFE certificate identifies a WORKLOAD, not a host.
func (c *ca) issue(spiffeID string) (*svid, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, err
}
uri, err := url.Parse(spiffeID)
if err != nil {
return nil, err
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, err
}
template := &x509.Certificate{
SerialNumber: serial,
NotBefore: time.Now().Add(-30 * time.Second), // allowance for clock skew
NotAfter: time.Now().Add(svidTTL),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{
x509.ExtKeyUsageServerAuth,
x509.ExtKeyUsageClientAuth,
},
URIs: []*url.URL{uri}, // <- the identity lives here
BasicConstraintsValid: true,
}
der, err := x509.CreateCertificate(rand.Reader, template, c.cert, &key.PublicKey, c.key)
if err != nil {
return nil, err
}
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
return nil, err
}
return &svid{
SpiffeID: spiffeID,
Cert: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})),
Key: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})),
Bundle: c.bundle,
TTLSeconds: int(svidTTL.Seconds()),
}, nil
}
// attest asks the kernel who is on the other end of this socket.
//
// SO_PEERCRED is the important bit. The operating system reports the connecting
// process's pid and uid, and a process cannot lie about them. We then read that
// pid's command line from /proc to decide which registration entry applies.
func attest(conn *net.UnixConn) (pid int32, uid uint32, cmdline string, err error) {
raw, err := conn.SyscallConn()
if err != nil {
return 0, 0, "", err
}
var creds *syscall.Ucred
var credErr error
if err := raw.Control(func(fd uintptr) {
creds, credErr = syscall.GetsockoptUcred(int(fd), syscall.SOL_SOCKET, syscall.SO_PEERCRED)
}); err != nil {
return 0, 0, "", err
}
if credErr != nil {
return 0, 0, "", credErr
}
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", creds.Pid))
if err != nil {
return creds.Pid, creds.Uid, "", err
}
// /proc stores arguments separated by NUL bytes.
return creds.Pid, creds.Uid, strings.ReplaceAll(string(data), "\x00", " "), nil
}
func (c *ca) handle(conn *net.UnixConn) {
defer conn.Close()
pid, uid, cmdline, err := attest(conn)
if err != nil {
log.Printf(" attestation failed: %v", err)
return
}
for _, e := range entries {
if !strings.Contains(cmdline, e.cmdlineContains) {
continue
}
s, err := c.issue(e.spiffeID)
if err != nil {
log.Printf(" issue failed: %v", err)
return
}
log.Printf(" issued %-38s pid=%d uid=%d ttl=%s", s.SpiffeID, pid, uid, svidTTL)
_ = json.NewEncoder(conn).Encode(s)
return
}
log.Printf(" no registration entry matches pid=%d (%s)", pid, strings.TrimSpace(cmdline))
}
func main() {
log.SetFlags(log.Ltime)
c, err := newCA()
if err != nil {
log.Fatalf("could not create CA: %v", err)
}
// A leftover socket file from a previous run would block us.
os.Remove(socketPath)
listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"})
if err != nil {
log.Fatalf("could not listen on %s: %v", socketPath, err)
}
defer os.Remove(socketPath)
fmt.Printf("The Workload API is running on %s (issues %s SVIDs)\n", socketPath, svidTTL)
for _, e := range entries {
fmt.Printf(" registered: cmdline contains %-18q -> %s\n", e.cmdlineContains, e.spiffeID)
}
for {
conn, err := listener.AcceptUnix()
if err != nil {
log.Printf(" accept failed: %v", err)
continue
}
go c.handle(conn)
}
}Test it right now
In another terminal, pretend to be a workload:
Nothing comes back, and the agent logs:
no registration entry matches pid=569950 (nc -U /tmp/spire-lite.sock)That is attestation refusing you. Read what just happened carefully: you had no secret to steal, and knowing the socket path did not help. There was nothing to guess.
Press Ctrl+C to stop nc, and leave the workload API running for Lesson 12.
What you have and have not built
| Claim | True? |
|---|---|
| Issues certificates with no credential from the caller | Yes |
| Identity survives a process moving to another machine | Yes, the ID has no address in it |
| Workload API writes no long-lived credential to disk | Yes, its CA is in memory; the old certs/ directory remains until Lesson 13 |
| Safe as a production attestation policy | No — one command-line substring is too weak |
| Highly available | No — it is one process, and Lesson 14 discusses the cost |
Common errors
| Error | Meaning | Fix |
|---|---|---|
undefined: syscall.GetsockoptUcred | You are not on Linux | Use WSL, a Linux VM, or a container |
bind: address already in use on the socket | A stale socket file | The code calls os.Remove(socketPath) at startup; check nothing else is running |
no registration entry matches for your own service | The process name is not in the table | Start it exactly as documented, or adjust the entry to your folder name |
permission denied connecting to the socket | The socket belongs to another user | Run both as the same user |
Lesson 11 checkpoint
You are ready for Lesson 12 when you can explain all of these without rereading:
- why a sixty-second certificate removes the everyday need for a revocation list, and what emergency controls it does not replace;
- what secret zero is, and how attestation removes it;
- why a process cannot lie about its pid to the workload API;
- why attestation requires a unix socket rather than a TCP port;
- what is deliberately missing from an SVID compared with the certificates in Lesson 5;
- why the registration table in this demo would be unsafe in production.
Next you will write the small library that knows how to talk to this agent, before rewiring anything to use it.