// Package authprojection owns the Edge-local immutable principal projection // cache. The cache is deliberately transport-neutral: callers install a // snapshot only after the surrounding transport has authenticated its source. package authprojection import ( "crypto/sha256" "crypto/subtle" "encoding/hex" "errors" "fmt" "strings" "sync/atomic" "time" "google.golang.org/protobuf/proto" iop "iop/proto/gen/iop" ) var ( ErrInvalidProjection = errors.New("authprojection: invalid projection") ErrProjectionTooLarge = errors.New("authprojection: projection exceeds configured bounds") ErrGenerationNotHigher = errors.New("authprojection: generation is not higher than the installed generation") ErrGenerationConflict = errors.New("authprojection: equal generation changes immutable content") ErrProjectionExpired = errors.New("authprojection: projection is expired") ErrBindingStale = errors.New("authprojection: credential binding is stale") ) // State distinguishes the compatibility default from managed authorization. // Once any verified snapshot is installed, expiry never returns the cache to // unmanaged mode; managed requests remain fail-closed. type State uint8 const ( StateUnmanaged State = iota StateFresh StateExpired ) func (s State) String() string { switch s { case StateUnmanaged: return "unmanaged" case StateFresh: return "fresh" case StateExpired: return "expired" default: return "unknown" } } // ValidateBinding is the immediate pre-lease and pre-send revocation fence. // It checks one atomic snapshot and requires an exact generation and route. func (c *Cache) ValidateBinding(binding *iop.CredentialLeaseBinding) error { if c == nil || binding == nil { return ErrBindingStale } installed := c.current.Load() if c.stateFor(installed) != StateFresh || installed.generation != binding.GetProjectionGeneration() { return ErrBindingStale } for _, route := range installed.routes { if route.PrincipalRef == binding.GetPrincipalRef() && route.CredentialSlotRef == binding.GetCredentialSlotRef() && route.RouteID == binding.GetRouteId() && route.ProfileID == binding.GetProfileId() && route.UpstreamModel == binding.GetUpstreamTarget() && route.RouteRevision == binding.GetRouteRevision() && route.CredentialRevision == binding.GetCredentialRevision() { return nil } } return ErrBindingStale } // Limits are hard in-memory and encoded-size bounds for one snapshot. type Limits struct { MaxTokens int MaxRoutes int MaxBytes int } func DefaultLimits() Limits { return Limits{ MaxTokens: 4096, MaxRoutes: 16384, MaxBytes: 4 << 20, } } func normalizeLimits(limits Limits) Limits { defaults := DefaultLimits() if limits.MaxTokens <= 0 { limits.MaxTokens = defaults.MaxTokens } if limits.MaxRoutes <= 0 { limits.MaxRoutes = defaults.MaxRoutes } if limits.MaxBytes <= 0 { limits.MaxBytes = defaults.MaxBytes } return limits } // Principal is safe authenticated caller metadata. It contains no raw token. type Principal struct { PrincipalRef string PrincipalAlias string TokenRef string TokenRevision uint64 } // Route is a secret-free immutable route binding retained for later secure // transport activation. type Route struct { RouteID string RouteAlias string PrincipalRef string CredentialSlotRef string ProfileID string UpstreamModel string ResourceSelector string RouteRevision uint64 CredentialRevision uint64 } // AuthenticatedView is the secret-free, request-local result of authenticating // a principal against one immutable projection snapshot. Routes are copied so // callers cannot mutate cache-owned state or observe a later generation. type AuthenticatedView struct { Principal Principal Routes []Route Generation uint64 State State } // Reader is the narrow authentication surface consumed by HTTP ingress. type Reader interface { AuthenticatedView([sha256.Size]byte) (AuthenticatedView, bool) LookupDigest([sha256.Size]byte) (Principal, State, bool) State() State Generation() (uint64, bool) RoutesForPrincipal(principalRef string) ([]Route, State) } // AuthenticatedView performs the digest scan and principal route collection // from one atomic snapshot load. Managed HTTP ingress must retain this value // for the whole request rather than mixing authentication and routing reads // across generations. func (c *Cache) AuthenticatedView(digest [sha256.Size]byte) (AuthenticatedView, bool) { if c == nil { return AuthenticatedView{State: StateUnmanaged}, false } installed := c.current.Load() state := c.stateFor(installed) if state != StateFresh { return AuthenticatedView{State: state}, false } matchedIndex := -1 found := 0 for index, token := range installed.tokens { equal := subtle.ConstantTimeCompare(digest[:], token.digest[:]) selectCurrent := equal & (found ^ 1) matchedIndex = subtle.ConstantTimeSelect(selectCurrent, index, matchedIndex) found |= equal } if found != 1 { return AuthenticatedView{State: state, Generation: installed.generation}, false } principal := installed.tokens[matchedIndex].principal view := AuthenticatedView{Principal: principal, Generation: installed.generation, State: state} for _, route := range installed.routes { if route.PrincipalRef == principal.PrincipalRef { view.Routes = append(view.Routes, route) } } return view, true } type projectedToken struct { digest [sha256.Size]byte principal Principal } type snapshot struct { generation uint64 issuedAt time.Time expiresAt time.Time tokens []projectedToken routes []Route } // Cache performs lock-free reads and atomic whole-snapshot swaps. type Cache struct { limits Limits now func() time.Time current atomic.Pointer[snapshot] } func NewCache(limits Limits, clock func() time.Time) *Cache { if clock == nil { clock = time.Now } return &Cache{limits: normalizeLimits(limits), now: clock} } // Apply validates and normalizes an immutable candidate. Higher generations // replace the installed snapshot. An equal generation may only advance the // freshness window when all authorization content is identical. func (c *Cache) Apply(projection *iop.PrincipalProjection) error { if c == nil || projection == nil { return fmt.Errorf("%w: projection is required", ErrInvalidProjection) } if len(projection.GetTokens()) > c.limits.MaxTokens { return fmt.Errorf("%w: tokens=%d max=%d", ErrProjectionTooLarge, len(projection.GetTokens()), c.limits.MaxTokens) } if len(projection.GetRoutes()) > c.limits.MaxRoutes { return fmt.Errorf("%w: routes=%d max=%d", ErrProjectionTooLarge, len(projection.GetRoutes()), c.limits.MaxRoutes) } if size := proto.Size(projection); size > c.limits.MaxBytes { return fmt.Errorf("%w: bytes=%d max=%d", ErrProjectionTooLarge, size, c.limits.MaxBytes) } issuedAt := time.Unix(0, projection.GetIssuedAtUnixNano()).UTC() expiresAt := time.Unix(0, projection.GetExpiresAtUnixNano()).UTC() if projection.GetIssuedAtUnixNano() <= 0 || projection.GetExpiresAtUnixNano() <= projection.GetIssuedAtUnixNano() { return fmt.Errorf("%w: invalid issued/expiry timestamps", ErrInvalidProjection) } validationNow := c.now() if issuedAt.After(validationNow) { return fmt.Errorf("%w: projection is not issued yet", ErrInvalidProjection) } if !validationNow.Before(expiresAt) { return ErrProjectionExpired } candidate := &snapshot{ generation: projection.GetGeneration(), issuedAt: issuedAt, expiresAt: expiresAt, tokens: make([]projectedToken, 0, len(projection.GetTokens())), routes: make([]Route, 0, len(projection.GetRoutes())), } seenDigests := make(map[string]struct{}, len(projection.GetTokens())) seenTokenRefs := make(map[string]struct{}, len(projection.GetTokens())) for index, token := range projection.GetTokens() { if token == nil { return fmt.Errorf("%w: token %d is nil", ErrInvalidProjection, index) } digestText := strings.ToLower(strings.TrimSpace(token.GetTokenDigestSha256())) digestBytes, err := hex.DecodeString(digestText) if err != nil || len(digestBytes) != sha256.Size { return fmt.Errorf("%w: token %d digest must be SHA-256 hex", ErrInvalidProjection, index) } if strings.TrimSpace(token.GetPrincipalRef()) == "" || strings.TrimSpace(token.GetTokenRef()) == "" { return fmt.Errorf("%w: token %d principal_ref and token_ref are required", ErrInvalidProjection, index) } if _, duplicate := seenDigests[digestText]; duplicate { return fmt.Errorf("%w: duplicate token digest", ErrInvalidProjection) } seenDigests[digestText] = struct{}{} tokenRef := strings.TrimSpace(token.GetTokenRef()) if _, duplicate := seenTokenRefs[tokenRef]; duplicate { return fmt.Errorf("%w: duplicate token reference", ErrInvalidProjection) } seenTokenRefs[tokenRef] = struct{}{} var digest [sha256.Size]byte copy(digest[:], digestBytes) candidate.tokens = append(candidate.tokens, projectedToken{ digest: digest, principal: Principal{ PrincipalRef: strings.TrimSpace(token.GetPrincipalRef()), PrincipalAlias: strings.TrimSpace(token.GetPrincipalAlias()), TokenRef: tokenRef, TokenRevision: token.GetTokenRevision(), }, }) } seenRouteSelectors := make(map[string]struct{}, 2*len(projection.GetRoutes())) for index, route := range projection.GetRoutes() { if route == nil { return fmt.Errorf("%w: route %d is nil", ErrInvalidProjection, index) } if strings.TrimSpace(route.GetRouteId()) == "" || strings.TrimSpace(route.GetPrincipalRef()) == "" || strings.TrimSpace(route.GetCredentialSlotRef()) == "" || strings.TrimSpace(route.GetProfileId()) == "" || strings.TrimSpace(route.GetUpstreamModel()) == "" || strings.TrimSpace(route.GetResourceSelector()) == "" { return fmt.Errorf("%w: route %d is missing required metadata", ErrInvalidProjection, index) } principalRef := strings.TrimSpace(route.GetPrincipalRef()) routeID := strings.TrimSpace(route.GetRouteId()) routeAlias := strings.TrimSpace(route.GetRouteAlias()) routeKey := principalRef + "\x00" + routeID if _, duplicate := seenRouteSelectors[routeKey]; duplicate { return fmt.Errorf("%w: ambiguous principal route selector", ErrInvalidProjection) } seenRouteSelectors[routeKey] = struct{}{} if routeAlias != "" { aliasKey := principalRef + "\x00" + routeAlias if _, duplicate := seenRouteSelectors[aliasKey]; duplicate { return fmt.Errorf("%w: ambiguous principal route selector", ErrInvalidProjection) } seenRouteSelectors[aliasKey] = struct{}{} } candidate.routes = append(candidate.routes, Route{ RouteID: routeID, RouteAlias: routeAlias, PrincipalRef: principalRef, CredentialSlotRef: strings.TrimSpace(route.GetCredentialSlotRef()), ProfileID: strings.TrimSpace(route.GetProfileId()), UpstreamModel: strings.TrimSpace(route.GetUpstreamModel()), ResourceSelector: strings.TrimSpace(route.GetResourceSelector()), RouteRevision: route.GetRouteRevision(), CredentialRevision: route.GetCredentialRevision(), }) } for { installed := c.current.Load() if installed != nil && candidate.generation < installed.generation { return fmt.Errorf("%w: candidate=%d installed=%d", ErrGenerationNotHigher, candidate.generation, installed.generation) } if installed != nil && candidate.generation == installed.generation { if !sameImmutableContent(candidate, installed) { return fmt.Errorf("%w: generation=%d", ErrGenerationConflict, candidate.generation) } if !candidate.issuedAt.After(installed.issuedAt) || !candidate.expiresAt.After(installed.expiresAt) { return nil } } if !c.now().Before(candidate.expiresAt) { return ErrProjectionExpired } if c.current.CompareAndSwap(installed, candidate) { return nil } } } func sameImmutableContent(left, right *snapshot) bool { if left == nil || right == nil || len(left.tokens) != len(right.tokens) || len(left.routes) != len(right.routes) { return false } tokens := make(map[projectedToken]int, len(left.tokens)) for _, token := range left.tokens { tokens[token]++ } for _, token := range right.tokens { if tokens[token] == 0 { return false } tokens[token]-- } routes := make(map[Route]int, len(left.routes)) for _, route := range left.routes { routes[route]++ } for _, route := range right.routes { if routes[route] == 0 { return false } routes[route]-- } return true } func (c *Cache) stateFor(installed *snapshot) State { if installed == nil { return StateUnmanaged } if !c.now().Before(installed.expiresAt) { return StateExpired } return StateFresh } func (c *Cache) State() State { if c == nil { return StateUnmanaged } return c.stateFor(c.current.Load()) } func (c *Cache) Generation() (uint64, bool) { if c == nil { return 0, false } installed := c.current.Load() if installed == nil { return 0, false } return installed.generation, true } // LookupDigest compares the supplied verifier digest against every bounded // candidate using crypto/subtle. The raw caller token never enters this cache. func (c *Cache) LookupDigest(digest [sha256.Size]byte) (Principal, State, bool) { view, ok := c.AuthenticatedView(digest) return view.Principal, view.State, ok } // RoutesForPrincipal returns a copy of the fresh principal route projection. // Managed expired state returns no routes and remains fail-closed. func (c *Cache) RoutesForPrincipal(principalRef string) ([]Route, State) { if c == nil { return nil, StateUnmanaged } installed := c.current.Load() state := c.stateFor(installed) if state != StateFresh { return nil, state } routes := make([]Route, 0) for _, route := range installed.routes { if route.PrincipalRef == principalRef { routes = append(routes, route) } } return routes, state } var _ Reader = (*Cache)(nil)