Back to Blog
Operations 2026-03-03

Atomic Certificate Rotation

How iddio renews TLS certificates without downtime. A background goroutine, a 30-day renewal window, atomic file writes via temp-and-rename, and SHA-256 fingerprinting for verification.

The Rotation Problem

TLS certificates expire. When iddio’s proxy cert expires, all agent connections fail — the TLS handshake rejects an expired certificate. Manual rotation (generate new cert, replace file, restart proxy) creates a maintenance window and a potential for human error.

Automatic certificate rotation eliminates this. The proxy monitors its own certificate’s expiry date and renews it before it expires, without downtime.

Rotation Architecture

A background goroutine checks the certificate’s expiry once per hour. When the certificate enters the renewal window (30 days before expiry by default), the proxy generates a new certificate, writes it atomically, and swaps it into the running TLS config:

func (p *Proxy) certRotationLoop(ctx context.Context) {
    ticker := time.NewTicker(1 * time.Hour)
    defer ticker.Stop()

    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            p.checkAndRotate()
        }
    }
}

func (p *Proxy) checkAndRotate() {
    cert, _ := tls.LoadX509KeyPair(p.certPath, p.keyPath)
    leaf, _ := x509.ParseCertificate(cert.Certificate[0])

    renewAfter := leaf.NotAfter.Add(-30 * 24 * time.Hour)
    if time.Now().Before(renewAfter) {
        return // Not yet in renewal window
    }

    log.Printf("certificate expires %s, renewing", leaf.NotAfter)
    p.rotateCert()
}

Atomic File Writes

Certificate files are written using the temp-and-rename pattern:

func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
    dir := filepath.Dir(path)
    tmp, err := os.CreateTemp(dir, ".tmp-*")
    if err != nil {
        return err
    }

    if _, err := tmp.Write(data); err != nil {
        tmp.Close()
        os.Remove(tmp.Name())
        return err
    }

    if err := tmp.Chmod(perm); err != nil {
        tmp.Close()
        os.Remove(tmp.Name())
        return err
    }

    tmp.Close()

    // Rename is atomic on POSIX filesystems
    return os.Rename(tmp.Name(), path)
}

This ensures that at no point is the certificate file partially written. Either the old cert exists or the new cert exists — never a corrupted intermediate state. The os.Rename call is atomic on all POSIX filesystems.

SHA-256 Fingerprinting

After writing the new certificate, the proxy verifies it by computing its SHA-256 fingerprint and comparing it to the expected value:

func (p *Proxy) rotateCert() error {
    // Generate new cert signed by CA
    newCert, newKey, err := p.ca.IssueCert(p.hostname, 365*24*time.Hour)
    if err != nil {
        return fmt.Errorf("cert issue: %w", err)
    }

    // Write atomically
    if err := atomicWriteFile(p.certPath, newCert, 0600); err != nil {
        return err
    }
    if err := atomicWriteFile(p.keyPath, newKey, 0600); err != nil {
        return err
    }

    // Verify by loading and fingerprinting
    loaded, err := tls.LoadX509KeyPair(p.certPath, p.keyPath)
    if err != nil {
        return fmt.Errorf("cert verify: %w", err)
    }

    leaf, _ := x509.ParseCertificate(loaded.Certificate[0])
    fp := sha256.Sum256(leaf.Raw)
    log.Printf("certificate rotated: fingerprint=%s, expires=%s",
        hex.EncodeToString(fp[:8]), leaf.NotAfter)

    // Swap into TLS config
    p.tlsConfig.Certificates = []tls.Certificate{loaded}
    return nil
}

The fingerprint is logged for audit purposes. If you need to verify that a specific certificate is in use, compare the logged fingerprint against the certificate file.

TLS Config Swap

Go’s tls.Config supports dynamic certificate selection via GetCertificate. Iddio uses this to swap certificates without restarting the listener:

tlsConfig := &tls.Config{
    GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
        p.certMu.RLock()
        defer p.certMu.RUnlock()
        return p.currentCert, nil
    },
    ClientAuth: tls.RequireAndVerifyClientCert,
    ClientCAs:  p.caPool,
}

When the certificate is rotated, the proxy acquires a write lock, swaps the cert reference, and releases the lock. In-flight TLS handshakes using the old cert complete normally. New handshakes use the new cert.

Rotation Timeline

EventTiming
Certificate issuedDay 0
Check intervalEvery 1 hour
Renewal window opensDay 335 (30 days before expiry)
New cert generatedFirst check after window opens
Certificate expiresDay 365

With a 365-day certificate and a 30-day renewal window, you have a full month of buffer. Even if the renewal fails repeatedly, the proxy will retry every hour — 720 attempts before the old cert expires.

Agent Certificates

Agent certificates are also rotated, but through a different mechanism. Since agents connect to the proxy (not the other way around), agent cert rotation is triggered by the iddio agent renew command or automatically by the enterprise control plane.

Agent certs use the same atomic write + fingerprint verification pattern. The agent doesn’t need to restart — kubectl re-reads the client cert on each connection.

Try It Yourself

Iddio is open source. Deploy a zero-trust command proxy for your AI agents in minutes.