Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ require (
github.com/fatih/semgroup v1.2.0
github.com/gitleaks/go-gitdiff v0.9.1
github.com/go-mysql-org/go-mysql v1.13.0
github.com/gofrs/flock v0.8.1
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/h2non/filetype v1.1.3
Expand Down Expand Up @@ -111,7 +112,6 @@ require (
github.com/go-openapi/swag v0.23.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/gofrs/flock v0.8.1 // indirect
github.com/gofrs/uuid v4.4.0+incompatible // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/glog v1.2.5 // indirect
Expand Down
183 changes: 0 additions & 183 deletions go.sum

Large diffs are not rendered by default.

74 changes: 74 additions & 0 deletions packages/agentproxy/ca.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,17 @@ const (
leafTTL = 24 * time.Hour
leafReuseMargin = 1 * time.Hour
maxLeafCacheEntries = 8192

// localRootTTL bounds the in-memory self-signed local root; just needs to outlast one session.
localRootTTL = 7 * 24 * time.Hour
)

type caManager struct {
token func() string

// local: the intermediate fields hold a self-signed root minted at construction, never re-signed.
local bool

mu sync.Mutex
intermediateKey *ecdsa.PrivateKey
intermediateCert *x509.Certificate
Expand All @@ -55,7 +61,75 @@ func newCaManager(token func() string) *caManager {
}
}

// generateLocalRoot mints a self-signed ECDSA P-256 root (P-256 so rustls-based agents accept it).
func generateLocalRoot() (*ecdsa.PrivateKey, *x509.Certificate, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, fmt.Errorf("failed to generate local root CA key: %w", err)
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, nil, err
}
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: "Infisical Agent Proxy Local Root CA"},
NotBefore: time.Now().Add(-1 * time.Minute),
NotAfter: time.Now().Add(localRootTTL),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
BasicConstraintsValid: true,
IsCA: true,
MaxPathLenZero: true,
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
return nil, nil, fmt.Errorf("failed to self-sign local root CA: %w", err)
}
cert, err := x509.ParseCertificate(der)
if err != nil {
return nil, nil, fmt.Errorf("failed to parse local root CA certificate: %w", err)
}
return key, cert, nil
}

// caManagerFromRoot wraps an existing local root key/cert as a caManager.
func caManagerFromRoot(key *ecdsa.PrivateKey, cert *x509.Certificate) *caManager {
return &caManager{
local: true,
intermediateKey: key,
intermediateCert: cert,
intermediateExp: cert.NotAfter,
leafCache: make(map[string]*leafEntry),
}
}

// newLocalCaManager builds a fresh in-memory local root. The private key never leaves memory.
func newLocalCaManager() (*caManager, error) {
key, cert, err := generateLocalRoot()
if err != nil {
return nil, err
}
return caManagerFromRoot(key, cert), nil
}

// RootPEM returns the local root's public certificate (nil outside local mode).
func (c *caManager) RootPEM() []byte {
if !c.local {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
if c.intermediateCert == nil {
return nil
}
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: c.intermediateCert.Raw})
}

func (c *caManager) ensureIntermediate() error {
// The local root is minted once at construction and is never re-signed.
if c.local {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()

Expand Down
67 changes: 50 additions & 17 deletions packages/agentproxy/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,8 +202,37 @@ func (a *agentCache) evictIfFullLocked(incoming string) {
}

func (a *agentCache) resolve(jwt string, scope agentScope) ([]*resolvedService, error) {
agentClient := resty.New().SetAuthToken(jwt)
listResp, err := api.CallListProxiedServices(agentClient, api.ListProxiedServicesRequest{
return resolveServices(scope, resolveParams{
discoveryToken: jwt,
valueToken: a.proxyToken,
includeNonProxyable: false,
registerDynamic: func(cred api.ProxiedServiceCredential, projectSlug string) *dynamicCredentialRef {
key := leaseKey{
jwt: jwt,
scope: scope,
secretName: cred.DynamicSecretName,
configHash: canonicalConfigHash(cred.DynamicSecretConfig),
}
a.leases.register(key, leaseSpec{projectSlug: projectSlug, config: cred.DynamicSecretConfig})
return &dynamicCredentialRef{key: key, field: cred.DynamicSecretField}
},
})
}

// resolveParams holds what differs between the remote (agentCache) and local (localResolver) resolvers.
type resolveParams struct {
discoveryToken string
valueToken func() string
includeNonProxyable bool
// registerDynamic maps a dynamic-secret credential to its lease ref, or returns nil to skip it.
registerDynamic func(cred api.ProxiedServiceCredential, projectSlug string) *dynamicCredentialRef
}

// resolveServices lists the proxied services for a scope and attaches credential values. Shared by
// both resolvers; the differences live in resolveParams.
func resolveServices(scope agentScope, p resolveParams) ([]*resolvedService, error) {
client := resty.New().SetAuthToken(p.discoveryToken)
listResp, err := api.CallListProxiedServices(client, api.ListProxiedServicesRequest{
ProjectID: scope.projectID,
Environment: scope.environment,
SecretPath: scope.secretPath,
Expand All @@ -212,11 +241,11 @@ func (a *agentCache) resolve(jwt string, scope agentScope) ([]*resolvedService,
return nil, fmt.Errorf("failed to discover proxied services: %w", err)
}

secretValues := a.fetchSecretValues(scope, referencedStaticKeys(listResp.Services))
secretValues := fetchSecretValues(p.valueToken, scope, referencedStaticKeys(listResp.Services, p.includeNonProxyable))

var services []*resolvedService
for _, svc := range listResp.Services {
if !svc.CanProxy {
if !p.includeNonProxyable && !svc.CanProxy {
continue
}
rs := &resolvedService{
Expand All @@ -227,21 +256,18 @@ func (a *agentCache) resolve(jwt string, scope agentScope) ([]*resolvedService,
}
for _, cred := range svc.Credentials {
if cred.DynamicSecretName != "" {
key := leaseKey{
jwt: jwt,
scope: scope,
secretName: cred.DynamicSecretName,
configHash: canonicalConfigHash(cred.DynamicSecretConfig),
ref := p.registerDynamic(cred, listResp.ProjectSlug)
if ref == nil {
continue
}
a.leases.register(key, leaseSpec{projectSlug: listResp.ProjectSlug, config: cred.DynamicSecretConfig})
rs.credentials = append(rs.credentials, resolvedCredential{
role: cred.Role,
headerName: cred.HeaderName,
headerPrefix: cred.HeaderPrefix,
headerPurpose: cred.HeaderPurpose,
placeholder: cred.PlaceholderValue,
surfaces: cred.SubstitutionSurfaces,
dynamic: &dynamicCredentialRef{key: key, field: cred.DynamicSecretField},
dynamic: ref,
})
continue
}
Expand Down Expand Up @@ -270,11 +296,11 @@ func (a *agentCache) resolve(jwt string, scope agentScope) ([]*resolvedService,
return services, nil
}

func referencedStaticKeys(services []api.ProxiedService) []string {
func referencedStaticKeys(services []api.ProxiedService, includeNonProxyable bool) []string {
seen := make(map[string]struct{})
var keys []string
for _, svc := range services {
if !svc.CanProxy {
if !includeNonProxyable && !svc.CanProxy {
continue
}
for _, cred := range svc.Credentials {
Expand All @@ -291,12 +317,12 @@ func referencedStaticKeys(services []api.ProxiedService) []string {
return keys
}

// fetchSecretValues resolves referenced static secrets individually so a key the proxy can't read is
// skipped rather than failing the whole agent.
func (a *agentCache) fetchSecretValues(scope agentScope, keys []string) map[string]string {
// fetchSecretValues resolves referenced static secrets individually so a key the value identity can't
// read is skipped rather than failing the whole agent.
func fetchSecretValues(token func() string, scope agentScope, keys []string) map[string]string {
values := make(map[string]string, len(keys))
for _, key := range keys {
secret, _, err := util.GetSinglePlainTextSecretByNameV3(a.proxyToken(), scope.projectID, scope.environment, scope.secretPath, key)
secret, _, err := util.GetSinglePlainTextSecretByNameV3(token(), scope.projectID, scope.environment, scope.secretPath, key)
if err != nil {
log.Warn().Err(err).Msgf("agent proxy: skipping static secret %q; proxy identity cannot read it", key)
continue
Expand All @@ -306,6 +332,13 @@ func (a *agentCache) fetchSecretValues(scope agentScope, keys []string) map[stri
return values
}

// close drops every cached entry so credential values become unreachable. Called at proxy shutdown.
func (a *agentCache) close() {
a.mu.Lock()
defer a.mu.Unlock()
a.entries = make(map[string]*agentEntry)
}

func (a *agentCache) refreshActive() {
type refreshTarget struct {
key string
Expand Down
132 changes: 132 additions & 0 deletions packages/agentproxy/local.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package agentproxy

import (
"sync"

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

// LocalOptions switches the proxy into local coupled mode (`agent-proxy run`): one developer, one
// agent, one scope. Requests carry no Proxy-Authorization; every request uses the scope and token
// fixed here, and API calls use the developer's own token.
type LocalOptions struct {
ProjectID string
Environment string
SecretPath string

// UserToken returns the developer's current access token; called per request so it can rotate.
UserToken func() string

// CADir, if set, persists the local root CA there (reused across runs and trustable in the OS
// trust store) instead of a fresh in-memory root. Must be a sandbox-denied path so the agent
// cannot read the key.
CADir string

// IdentityID/IdentityName label activity records (no wire JWT to decode them from).
IdentityID string
IdentityName string
}

func (l *LocalOptions) scope() agentScope {
return agentScope{projectID: l.ProjectID, environment: l.Environment, secretPath: l.SecretPath}
}

// localResolver is the single-snapshot, single-scope counterpart of agentCache (no keyed map, since
// the one caller is fixed at startup). The snapshot refreshes on the poll loop and is dropped
// (fail closed) when the developer's authorization goes away.
type localResolver struct {
opts *LocalOptions

mu sync.Mutex
services []*resolvedService
valid bool

dynamicWarned bool
}

func newLocalResolver(opts *LocalOptions) *localResolver {
return &localResolver{opts: opts}
}

func (l *localResolver) get(_ string, _ agentScope) ([]*resolvedService, error) {
l.mu.Lock()
if l.valid {
snapshot := l.services
l.mu.Unlock()
return snapshot, nil
}
l.mu.Unlock()

resolved, err := l.resolveSnapshot()
if err != nil {
return nil, err
}
l.mu.Lock()
l.services = resolved
l.valid = true
l.mu.Unlock()
return resolved, nil
}

func (l *localResolver) resolveSnapshot() ([]*resolvedService, error) {
// One identity for discovery and value-fetch, and no CanProxy filter: locally the gate is Read
// Value alone.
token := l.opts.UserToken()
return resolveServices(l.opts.scope(), resolveParams{
discoveryToken: token,
valueToken: func() string { return token },
includeNonProxyable: true,
registerDynamic: func(cred api.ProxiedServiceCredential, _ string) *dynamicCredentialRef {
l.mu.Lock()
warned := l.dynamicWarned
l.dynamicWarned = true
l.mu.Unlock()
if !warned {
log.Warn().Msgf("proxied service references dynamic secret %q; dynamic secrets are not supported in local mode, skipping those credentials", cred.DynamicSecretName)
}
return nil
},
})
}

func (l *localResolver) identity(_ string, _ agentScope) (string, string, bool) {
return l.opts.IdentityID, l.opts.IdentityName, true
}

func (l *localResolver) refreshActive() {
l.mu.Lock()
haveSnapshot := l.valid
l.mu.Unlock()
if !haveSnapshot {
return
}

resolved, err := l.resolveSnapshot()
if err != nil {
if isAuthError(err) {
log.Warn().Err(err).Msg("developer authorization no longer valid; dropping brokered credentials")
l.close()
return
}
log.Warn().Err(err).Msg("failed to refresh brokered credentials; keeping the previous snapshot")
return
}
l.mu.Lock()
l.services = resolved
l.valid = true
l.mu.Unlock()
}

// activeJWTs is for the lease loop; local mode registers no leases.
func (l *localResolver) activeJWTs() map[string]struct{} {
return map[string]struct{}{}
}

// close drops the snapshot (fail closed); the next request must re-resolve.
func (l *localResolver) close() {
l.mu.Lock()
defer l.mu.Unlock()
l.services = nil
l.valid = false
}
Loading
Loading