Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
8a9ea27
feat: secrets agent-proxy start and connect commands
saifsmailbox98 Jul 9, 2026
31e0ea8
fix: address pre-PR review for agent proxy
saifsmailbox98 Jul 9, 2026
b114ad4
docs: note substring-replacement behavior in credential substitution
saifsmailbox98 Jul 10, 2026
f87937d
refactor: address review feedback for agent proxy
saifsmailbox98 Jul 10, 2026
491d4ff
fix: fall back to still-valid intermediate CA when re-sign fails
saifsmailbox98 Jul 10, 2026
bd2006f
feat: broker plain-HTTP upstreams via absolute-form forward proxying
saifsmailbox98 Jul 10, 2026
b968e07
fix: skip placeholders of disabled proxied services in connect
saifsmailbox98 Jul 10, 2026
3731285
fix: break proxied service host-match ties deterministically by name
saifsmailbox98 Jul 10, 2026
9fe18cb
test: cover credential rewrite surfaces, prefixes, and body edge cases
saifsmailbox98 Jul 10, 2026
4025bba
fix: strip hop-by-hop headers before injecting so credentials always win
saifsmailbox98 Jul 10, 2026
415e65d
feat: merge operator NO_PROXY entries and add --no-proxy flag to connect
saifsmailbox98 Jul 10, 2026
c5e2e3c
fix: force HTTP/1.1 upstream so HTTP/2 responses don't hang the MITM …
saifsmailbox98 Jul 10, 2026
1a9f187
fix: authenticate before minting proxy leaf certs and bound leaf cache
saifsmailbox98 Jul 10, 2026
dd4ba4d
fix: pin upstream Host header to the matched authority
saifsmailbox98 Jul 10, 2026
f7f4c1b
chore: trim non-essential comments from agent proxy code
saifsmailbox98 Jul 10, 2026
392116c
feat: expand references and include imports when fetching proxied sec…
saifsmailbox98 Jul 11, 2026
4dbe55a
feat: match IPv6 host patterns in the agent proxy
saifsmailbox98 Jul 13, 2026
486c678
fix(agentproxy): bound request header size to prevent memory exhaustion
saifsmailbox98 Jul 13, 2026
f2ed427
fix(agentproxy): cap resolution cache with LRU eviction to bound memory
saifsmailbox98 Jul 13, 2026
cbf540a
refactor(agentproxy): serve via http.Server with connection cap and l…
saifsmailbox98 Jul 13, 2026
194fbc7
feat(agent-proxy): block connect when agent can read brokered secrets
saifsmailbox98 Jul 13, 2026
5511cd2
fix(agent-proxy): stay silent when the agent has no readable secrets …
saifsmailbox98 Jul 13, 2026
5af76b0
docs(agent-proxy): use <proxy-host> placeholder in --proxy examples
saifsmailbox98 Jul 13, 2026
ab29419
fix(agent-proxy): bound substitution expansion and add per-request de…
saifsmailbox98 Jul 14, 2026
61b2ab1
docs(agent-proxy): include --projectId in connect --help example
saifsmailbox98 Jul 14, 2026
add2c58
fix(agent-proxy): reject TRACE/TRACK and redact reflected credentials…
saifsmailbox98 Jul 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 210 additions & 0 deletions packages/agentproxy/ca.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
package agentproxy

import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"net"
"sync"
"sync/atomic"
"time"

"github.com/Infisical/infisical-merge/packages/api"
"github.com/go-resty/resty/v2"
"github.com/rs/zerolog/log"
)

const (
intermediateRenewThreshold = 12 * time.Hour
intermediateFallbackMargin = 5 * time.Minute
intermediateRetryInterval = 30 * time.Second
leafTTL = 24 * time.Hour
leafReuseMargin = 1 * time.Hour
maxLeafCacheEntries = 8192
)

type caManager struct {
token func() string

mu sync.Mutex
intermediateKey *ecdsa.PrivateKey
intermediateCert *x509.Certificate
intermediateExp time.Time
lastResignAttempt time.Time
resignGen atomic.Uint64

leafMu sync.Mutex
leafCache map[string]*leafEntry
}

type leafEntry struct {
cert tls.Certificate
expiration time.Time
}

func newCaManager(token func() string) *caManager {
return &caManager{
token: token,
leafCache: make(map[string]*leafEntry),
}
}

func (c *caManager) ensureIntermediate() error {
c.mu.Lock()
defer c.mu.Unlock()

remaining := time.Until(c.intermediateExp)
if c.intermediateCert != nil && remaining > intermediateRenewThreshold {
return nil
}

canFallBack := c.intermediateCert != nil && remaining > intermediateFallbackMargin
if canFallBack && time.Since(c.lastResignAttempt) < intermediateRetryInterval {
return nil
}
c.lastResignAttempt = time.Now()

if err := c.resignIntermediateLocked(); err != nil {
if canFallBack {
log.Warn().Err(err).Msg("failed to renew the intermediate CA; continuing with the current one until it nears expiry")
return nil
}
return err
}
return nil
}

func (c *caManager) resignIntermediateLocked() error {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return fmt.Errorf("failed to generate intermediate CA key: %w", err)
}

pubDer, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
if err != nil {
return fmt.Errorf("failed to marshal intermediate public key: %w", err)
}
pubPem := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDer})

client := resty.New().SetAuthToken(c.token())
resp, err := api.CallSignAgentProxyIntermediateCa(client, api.SignAgentProxyIntermediateCaRequest{
PublicKey: string(pubPem),
})
if err != nil {
return fmt.Errorf("failed to get intermediate CA signed: %w", err)
}

block, _ := pem.Decode([]byte(resp.Certificate))
if block == nil {
return fmt.Errorf("invalid intermediate CA certificate returned")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return fmt.Errorf("failed to parse intermediate CA certificate: %w", err)
}

c.intermediateKey = key
c.intermediateCert = cert
c.intermediateExp = cert.NotAfter
c.resignGen.Add(1)
c.leafMu.Lock()
c.leafCache = make(map[string]*leafEntry)
c.leafMu.Unlock()

return nil
}

func (c *caManager) mintLeaf(hostname string) (tls.Certificate, error) {
if err := c.ensureIntermediate(); err != nil {
return tls.Certificate{}, err
}

c.leafMu.Lock()
if entry, ok := c.leafCache[hostname]; ok && time.Until(entry.expiration) > leafReuseMargin {
c.leafMu.Unlock()
return entry.cert, nil
}
c.leafMu.Unlock()

c.mu.Lock()
interKey := c.intermediateKey
interCert := c.intermediateCert
gen := c.resignGen.Load()
c.mu.Unlock()

key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return tls.Certificate{}, err
}

serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return tls.Certificate{}, err
}

notAfter := time.Now().Add(leafTTL)
// a leaf must never outlive its issuer, or clients would reject the chain near intermediate expiry
if notAfter.After(interCert.NotAfter) {
notAfter = interCert.NotAfter
}
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: hostname},
NotBefore: time.Now().Add(-1 * time.Minute),
NotAfter: notAfter,
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
if ip := net.ParseIP(hostname); ip != nil {
template.IPAddresses = []net.IP{ip}
} else {
template.DNSNames = []string{hostname}
}

leafDer, err := x509.CreateCertificate(rand.Reader, template, interCert, &key.PublicKey, interKey)
if err != nil {
return tls.Certificate{}, err
}

cert := tls.Certificate{
Certificate: [][]byte{leafDer, interCert.Raw},
PrivateKey: key,
}

// Skip caching if a re-sign happened since snapshotting: it cleared leafCache and this leaf chains to the outgoing intermediate.
c.leafMu.Lock()
if c.resignGen.Load() == gen {
c.evictLeavesIfFullLocked(hostname)
c.leafCache[hostname] = &leafEntry{cert: cert, expiration: notAfter}
}
c.leafMu.Unlock()

return cert, nil
}

func (c *caManager) evictLeavesIfFullLocked(incoming string) {
if len(c.leafCache) < maxLeafCacheEntries {
return
}
if _, replacing := c.leafCache[incoming]; replacing {
return
}
now := time.Now()
for host, entry := range c.leafCache {
if now.After(entry.expiration) {
delete(c.leafCache, host)
}
}
for host := range c.leafCache {
if len(c.leafCache) < maxLeafCacheEntries {
break
}
delete(c.leafCache, host)
}
}
83 changes: 83 additions & 0 deletions packages/agentproxy/ca_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package agentproxy

import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"math/big"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/Infisical/infisical-merge/packages/config"
)

func installTestIntermediate(t *testing.T, c *caManager, notAfter time.Time) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "test intermediate"},
NotBefore: time.Now().Add(-1 * time.Hour),
NotAfter: notAfter,
IsCA: true,
BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageCertSign,
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
cert, err := x509.ParseCertificate(der)
if err != nil {
t.Fatal(err)
}
c.intermediateKey = key
c.intermediateCert = cert
c.intermediateExp = cert.NotAfter
}

func TestEnsureIntermediateFallsBackWhenResignFails(t *testing.T) {
failing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", http.StatusInternalServerError)
}))
defer failing.Close()
origURL := config.INFISICAL_URL
config.INFISICAL_URL = failing.URL
defer func() { config.INFISICAL_URL = origURL }()

c := newCaManager(func() string { return "test-token" })
installTestIntermediate(t, c, time.Now().Add(1*time.Hour))

if err := c.ensureIntermediate(); err != nil {
t.Fatalf("expected fallback to the valid intermediate, got error: %v", err)
}
leaf, err := c.mintLeaf("api.example.com")
if err != nil {
t.Fatalf("expected minting to keep working on the old intermediate, got: %v", err)
}
if len(leaf.Certificate) != 2 {
t.Fatalf("expected leaf + intermediate chain, got %d certs", len(leaf.Certificate))
}
}

func TestEnsureIntermediateFailsWithoutFallback(t *testing.T) {
failing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", http.StatusInternalServerError)
}))
defer failing.Close()
origURL := config.INFISICAL_URL
config.INFISICAL_URL = failing.URL
defer func() { config.INFISICAL_URL = origURL }()

c := newCaManager(func() string { return "test-token" })
if err := c.ensureIntermediate(); err == nil {
t.Fatal("expected an error when there is no intermediate to fall back to")
}
}
Loading
Loading