// Package credentialops provides a principal-authenticated, in-process // management service for credential slots and routes. It sits between the // wire layer and the credentialstore and enforces three invariants that the // store alone cannot guarantee: // // 1. Authentication — every request is bound to a principal resolved from a // presented IOP token digest. The raw token is hashed inside the service and never passed to the store. // // 2. Target ownership — callers may only mutate resources that belong to // their own principal. Cross-principal references are rejected. // // 3. Secret blindness — the service accepts plaintext secrets at the // method boundary, hands an opaque encrypted envelope to the store, and // never exposes raw secret material in responses, logs, or errors. // // The service is transport-neutral. It does not register listeners, declare // protobuf messages, or depend on any network adapter. The production // composition may supply a SecretSealer; mutating methods that touch secret // material fail closed when the sealer is unset. package credentialops import ( "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "io" "strings" "time" "github.com/google/uuid" "go.uber.org/zap" "iop/apps/control-plane/internal/credentialseal" "iop/apps/control-plane/internal/credentialstore" ) // SecretSealer is the injected boundary for at-rest secret encryption. // Implementations must produce an opaque ciphertext envelope that the // credentialstore persists; the raw plaintext must never leave the method // boundary. Mutating methods that require one return ErrSealerUnavailable // when composition leaves it unset. // // The seal context is the lower-layer credentialseal.Context so this service // depends on the crypto primitive rather than the reverse, avoiding any layer // inversion. type SecretSealer interface { Seal(ctx context.Context, plaintext []byte, sealCtx credentialseal.Context) (credentialstore.SecretEnvelope, error) } // ErrSealerUnavailable is returned by mutating methods when no SecretSealer // is configured on the service. var ErrSealerUnavailable = errors.New("credentialops: sealer is not configured") // ErrInvalidCredentialKind is returned when the caller supplies a // credential kind that is not one of the recognized canonical values. var ErrInvalidCredentialKind = errors.New("credentialops: invalid credential kind") // ErrUnauthorized is returned when the presented IOP token does not resolve // to an active principal or when the caller targets a resource that does // not belong to the resolved principal. var ErrUnauthorized = errors.New("credentialops: unauthorized") // ErrNotFound is returned when a requested slot or route is not found. var ErrNotFound = errors.New("credentialops: not found") // ErrStaleRevision is returned when a CAS operation fails because the // caller's revision does not match the current row revision. var ErrStaleRevision = errors.New("credentialops: stale revision") // ErrSecretHandlingFailed is returned when secret sealing, unsealing, or envelope validation fails. // It ensures sensitive sealer error strings or key metadata never leak across the boundary. var ErrSecretHandlingFailed = errors.New("credentialops: secret handling failed") // IssuedToken pairs the token DTO with the one-time raw token string returned upon token creation. type IssuedToken struct { Token TokenRecord RawToken string } // SlotRecord is the secret-blind DTO returned by slot queries. It exposes // only metadata; the plaintext IOP token, provider secret, and opaque // encrypted envelope are never present. type SlotRecord struct { ID string Vendor string CredentialKind string Alias string Status string Revision int64 CreatedAt time.Time UpdatedAt time.Time RevokedAt *time.Time } // RouteRecord is the DTO returned by route queries. It is a thin projection // of the credentialstore.RouteRecord with no secret material. type RouteRecord struct { ID string SlotID string Alias string ProfileID string UpstreamModel string ResourceSelector string Status string Revision int64 CreatedAt time.Time UpdatedAt time.Time RevokedAt *time.Time } // TokenRecord is the digest-free DTO returned by token operations. It exposes // metadata and opaque token reference; the raw token and digest are never present. type TokenRecord struct { ID string TokenRef string Status string Revision int64 CreatedAt time.Time UpdatedAt time.Time RevokedAt *time.Time } // CreateSlotInput holds parameters for creating a new credential slot. type CreateSlotInput struct { Vendor string CredentialKind string Alias string ProviderSecret []byte } // RotateSlotInput holds parameters for rotating a slot's secret envelope. type RotateSlotInput struct { SlotID string Revision int64 ProviderSecret []byte } // CreateRouteInput holds parameters for creating a new route binding. type CreateRouteInput struct { SlotID string Alias string ProfileID string UpstreamModel string ResourceSelector string } // UpdateRouteInput holds parameters for updating an existing route binding. type UpdateRouteInput struct { RouteID string CurrentRevision int64 SlotID string Alias string ProfileID string UpstreamModel string ResourceSelector string } // Service is the principal-scoped credential management service. It is // transport-neutral and does not register listeners or protobuf messages. type Service struct { store *credentialstore.Store logger *zap.Logger sealer SecretSealer randomReader io.Reader } // NewService constructs a Service bound to the given store. The sealer is // optional; mutating methods that require it return ErrSealerUnavailable // when none is configured. The randomReader is used for internal // randomness and may be nil (the crypto/rand default is used). func NewService(store *credentialstore.Store, logger *zap.Logger, sealer SecretSealer, randomReader io.Reader) *Service { return &Service{ store: store, logger: logger, sealer: sealer, randomReader: randomReader, } } // authenticate resolves the presented IOP token to a principal. The raw // token bytes are hashed in process; only the SHA-256 digest is compared // against the store. The returned principal ID and token record are the // only values passed to downstream store calls. func (s *Service) authenticate(ctx context.Context, rawIOPToken []byte) (string, *credentialstore.TokenRecord, error) { if len(rawIOPToken) == 0 { return "", nil, ErrUnauthorized } digest := sha256Hex(rawIOPToken) p, t, err := s.store.LookupTokenByDigest(ctx, digest) if err != nil { if errors.Is(err, credentialstore.ErrTokenNotFound) { return "", nil, ErrUnauthorized } return "", nil, fmt.Errorf("credentialops: authenticate: %w", err) } return p.ID, t, nil } // requireOwner verifies that the requested resource belongs to the // authenticated principal. It returns ErrNotFound when the ownership // check fails. func (s *Service) requireOwner(ctx context.Context, principalID string, slotID string) error { _, err := s.store.GetSlot(ctx, principalID, slotID) if err != nil { if errors.Is(err, credentialstore.ErrSlotNotFound) { return ErrNotFound } return fmt.Errorf("credentialops: require owner: %w", err) } return nil } // requireOwnerRoute verifies that the requested route belongs to the // authenticated principal. func (s *Service) requireOwnerRoute(ctx context.Context, principalID string, routeID string) error { _, err := s.store.GetRoute(ctx, principalID, routeID) if err != nil { if errors.Is(err, credentialstore.ErrRouteNotFound) { return ErrNotFound } return fmt.Errorf("credentialops: require owner route: %w", err) } return nil } // CreateSlot seals the caller-supplied provider secret and creates a slot for the authenticated principal. func (s *Service) CreateSlot(ctx context.Context, rawIOPToken []byte, input CreateSlotInput) (SlotRecord, error) { principalID, _, err := s.authenticate(ctx, rawIOPToken) if err != nil { return SlotRecord{}, err } if s.sealer == nil { return SlotRecord{}, ErrSealerUnavailable } owned := append([]byte(nil), input.ProviderSecret...) defer clear(owned) // Allocate the stable slot id before sealing so the create-time AAD binds to // the exact slot that will persist the ciphertext. slotID := uuid.NewString() kind, err := canonicalCredentialKind(input.CredentialKind) if err != nil { return SlotRecord{}, err } sealCtx := credentialseal.Context{ PrincipalID: principalID, SlotID: slotID, Kind: kind, } sealed, err := s.sealer.Seal(ctx, owned, sealCtx) if err != nil { return SlotRecord{}, ErrSecretHandlingFailed } created, err := s.store.CreateSlot(ctx, credentialstore.CreateSlotInput{ SlotID: slotID, PrincipalID: principalID, Vendor: input.Vendor, CredentialKind: kind, Alias: input.Alias, Envelope: sealed, }) if err != nil { return SlotRecord{}, mapStoreError(err) } return slotRecordFromSlot(created), nil } // RotateSlot rotates the secret envelope on a slot. The caller presents // the raw IOP token, input params, and provider secret. func (s *Service) RotateSlot(ctx context.Context, rawIOPToken []byte, input RotateSlotInput) (SlotRecord, error) { principalID, _, err := s.authenticate(ctx, rawIOPToken) if err != nil { return SlotRecord{}, err } if err := s.requireOwner(ctx, principalID, input.SlotID); err != nil { return SlotRecord{}, err } if s.sealer == nil { return SlotRecord{}, ErrSealerUnavailable } slot, err := s.store.GetSlot(ctx, principalID, input.SlotID) if err != nil { return SlotRecord{}, mapStoreError(err) } sealCtx := credentialseal.Context{ PrincipalID: principalID, SlotID: input.SlotID, Kind: slot.CredentialKind, } owned := append([]byte(nil), input.ProviderSecret...) defer clear(owned) sealed, err := s.sealer.Seal(ctx, owned, sealCtx) if err != nil { return SlotRecord{}, ErrSecretHandlingFailed } rotated, err := s.store.RotateSlotSecret(ctx, credentialstore.RotateSlotSecretInput{ PrincipalID: principalID, SlotID: input.SlotID, CurrentRevision: input.Revision, Envelope: sealed, }) if err != nil { return SlotRecord{}, mapStoreError(err) } return slotRecordFromSlot(rotated), nil } // DisableSlot transitions an active slot to disabled using CAS on revision. func (s *Service) DisableSlot(ctx context.Context, rawIOPToken []byte, slotID string, revision int64) (SlotRecord, error) { principalID, _, err := s.authenticate(ctx, rawIOPToken) if err != nil { return SlotRecord{}, err } if err := s.requireOwner(ctx, principalID, slotID); err != nil { return SlotRecord{}, err } disabled, err := s.store.DisableSlot(ctx, principalID, slotID, revision) if err != nil { return SlotRecord{}, mapStoreError(err) } return slotRecordFromSlot(disabled), nil } // EnableSlot transitions a disabled slot back to active using CAS on revision. func (s *Service) EnableSlot(ctx context.Context, rawIOPToken []byte, slotID string, revision int64) (SlotRecord, error) { principalID, _, err := s.authenticate(ctx, rawIOPToken) if err != nil { return SlotRecord{}, err } if err := s.requireOwner(ctx, principalID, slotID); err != nil { return SlotRecord{}, err } enabled, err := s.store.EnableSlot(ctx, principalID, slotID, revision) if err != nil { return SlotRecord{}, mapStoreError(err) } return slotRecordFromSlot(enabled), nil } // RevokeSlot permanently revokes a slot using CAS on revision. func (s *Service) RevokeSlot(ctx context.Context, rawIOPToken []byte, slotID string, revision int64) (SlotRecord, error) { principalID, _, err := s.authenticate(ctx, rawIOPToken) if err != nil { return SlotRecord{}, err } if err := s.requireOwner(ctx, principalID, slotID); err != nil { return SlotRecord{}, err } revoked, err := s.store.RevokeSlot(ctx, principalID, slotID, revision) if err != nil { return SlotRecord{}, mapStoreError(err) } return slotRecordFromSlot(revoked), nil } // GetSlot returns a secret-blind DTO for a single slot. func (s *Service) GetSlot(ctx context.Context, rawIOPToken []byte, slotID string) (SlotRecord, error) { principalID, _, err := s.authenticate(ctx, rawIOPToken) if err != nil { return SlotRecord{}, err } if err := s.requireOwner(ctx, principalID, slotID); err != nil { return SlotRecord{}, err } slot, err := s.store.GetSlot(ctx, principalID, slotID) if err != nil { return SlotRecord{}, mapStoreError(err) } return slotRecordFromSlot(slot), nil } // ListSlots returns all secret-blind slot DTOs for the authenticated principal. func (s *Service) ListSlots(ctx context.Context, rawIOPToken []byte) ([]SlotRecord, error) { principalID, _, err := s.authenticate(ctx, rawIOPToken) if err != nil { return nil, err } slots, err := s.store.ListSlots(ctx, principalID) if err != nil { return nil, fmt.Errorf("credentialops: list slots: %w", err) } out := make([]SlotRecord, len(slots)) for i := range slots { out[i] = slotRecordFromSlot(&slots[i]) } return out, nil } // CreateRoute binds a new route to an owned slot for the authenticated principal. func (s *Service) CreateRoute(ctx context.Context, rawIOPToken []byte, input CreateRouteInput) (RouteRecord, error) { principalID, _, err := s.authenticate(ctx, rawIOPToken) if err != nil { return RouteRecord{}, err } if err := s.requireOwner(ctx, principalID, input.SlotID); err != nil { return RouteRecord{}, err } route, err := s.store.CreateRoute(ctx, credentialstore.CreateRouteInput{ PrincipalID: principalID, SlotID: input.SlotID, Alias: input.Alias, ProfileID: input.ProfileID, UpstreamModel: input.UpstreamModel, ResourceSelector: input.ResourceSelector, }) if err != nil { return RouteRecord{}, mapStoreError(err) } return routeRecordFromRoute(route), nil } // UpdateRoute updates an existing route binding under CAS on revision. func (s *Service) UpdateRoute(ctx context.Context, rawIOPToken []byte, input UpdateRouteInput) (RouteRecord, error) { principalID, _, err := s.authenticate(ctx, rawIOPToken) if err != nil { return RouteRecord{}, err } if err := s.requireOwnerRoute(ctx, principalID, input.RouteID); err != nil { return RouteRecord{}, err } if err := s.requireOwner(ctx, principalID, input.SlotID); err != nil { return RouteRecord{}, err } updated, err := s.store.UpdateRoute(ctx, credentialstore.UpdateRouteInput{ PrincipalID: principalID, RouteID: input.RouteID, CurrentRevision: input.CurrentRevision, SlotID: input.SlotID, Alias: input.Alias, ProfileID: input.ProfileID, UpstreamModel: input.UpstreamModel, ResourceSelector: input.ResourceSelector, }) if err != nil { return RouteRecord{}, mapStoreError(err) } return routeRecordFromRoute(updated), nil } // GetRoute returns a DTO for a single route. func (s *Service) GetRoute(ctx context.Context, rawIOPToken []byte, routeID string) (RouteRecord, error) { principalID, _, err := s.authenticate(ctx, rawIOPToken) if err != nil { return RouteRecord{}, err } if err := s.requireOwnerRoute(ctx, principalID, routeID); err != nil { return RouteRecord{}, err } route, err := s.store.GetRoute(ctx, principalID, routeID) if err != nil { return RouteRecord{}, mapStoreError(err) } return routeRecordFromRoute(route), nil } // ListRoutes returns all route DTOs for the authenticated principal. func (s *Service) ListRoutes(ctx context.Context, rawIOPToken []byte) ([]RouteRecord, error) { principalID, _, err := s.authenticate(ctx, rawIOPToken) if err != nil { return nil, err } routes, err := s.store.ListRoutes(ctx, principalID) if err != nil { return nil, fmt.Errorf("credentialops: list routes: %w", err) } out := make([]RouteRecord, len(routes)) for i := range routes { out[i] = routeRecordFromRoute(&routes[i]) } return out, nil } // DisableRoute transitions an active route to disabled using CAS on revision. func (s *Service) DisableRoute(ctx context.Context, rawIOPToken []byte, routeID string, revision int64) (RouteRecord, error) { principalID, _, err := s.authenticate(ctx, rawIOPToken) if err != nil { return RouteRecord{}, err } if err := s.requireOwnerRoute(ctx, principalID, routeID); err != nil { return RouteRecord{}, err } disabled, err := s.store.DisableRoute(ctx, principalID, routeID, revision) if err != nil { return RouteRecord{}, mapStoreError(err) } return routeRecordFromRoute(disabled), nil } // EnableRoute transitions a disabled route back to active using CAS on revision. func (s *Service) EnableRoute(ctx context.Context, rawIOPToken []byte, routeID string, revision int64) (RouteRecord, error) { principalID, _, err := s.authenticate(ctx, rawIOPToken) if err != nil { return RouteRecord{}, err } if err := s.requireOwnerRoute(ctx, principalID, routeID); err != nil { return RouteRecord{}, err } enabled, err := s.store.EnableRoute(ctx, principalID, routeID, revision) if err != nil { return RouteRecord{}, mapStoreError(err) } return routeRecordFromRoute(enabled), nil } // RevokeRoute permanently revokes a route using CAS on revision. func (s *Service) RevokeRoute(ctx context.Context, rawIOPToken []byte, routeID string, revision int64) (RouteRecord, error) { principalID, _, err := s.authenticate(ctx, rawIOPToken) if err != nil { return RouteRecord{}, err } if err := s.requireOwnerRoute(ctx, principalID, routeID); err != nil { return RouteRecord{}, err } revoked, err := s.store.RevokeRoute(ctx, principalID, routeID, revision) if err != nil { return RouteRecord{}, mapStoreError(err) } return routeRecordFromRoute(revoked), nil } // CreateToken issues a new active IOP token for the authenticated principal. // The raw token string is returned exactly once in IssuedToken and is never persisted or retrievable. func (s *Service) CreateToken(ctx context.Context, rawIOPToken []byte) (IssuedToken, error) { principalID, _, err := s.authenticate(ctx, rawIOPToken) if err != nil { return IssuedToken{}, err } issued, err := s.store.CreateToken(ctx, principalID) if err != nil { return IssuedToken{}, mapStoreError(err) } return IssuedToken{ Token: tokenRecordFromToken(&issued.Token), RawToken: issued.RawToken, }, nil } // ListTokens returns all digest-free token records for the authenticated principal. func (s *Service) ListTokens(ctx context.Context, rawIOPToken []byte) ([]TokenRecord, error) { principalID, _, err := s.authenticate(ctx, rawIOPToken) if err != nil { return nil, err } tokens, err := s.store.ListTokens(ctx, principalID) if err != nil { return nil, mapStoreError(err) } out := make([]TokenRecord, len(tokens)) for i := range tokens { out[i] = tokenRecordFromToken(&tokens[i]) } return out, nil } // DisableToken transitions an active token to disabled using CAS on revision. func (s *Service) DisableToken(ctx context.Context, rawIOPToken []byte, tokenRef string, revision int64) (TokenRecord, error) { principalID, _, err := s.authenticate(ctx, rawIOPToken) if err != nil { return TokenRecord{}, err } disabled, err := s.store.DisableToken(ctx, principalID, tokenRef, revision) if err != nil { return TokenRecord{}, mapStoreError(err) } return tokenRecordFromToken(disabled), nil } // RevokeToken permanently revokes a token using CAS on revision. func (s *Service) RevokeToken(ctx context.Context, rawIOPToken []byte, tokenRef string, revision int64) (TokenRecord, error) { principalID, _, err := s.authenticate(ctx, rawIOPToken) if err != nil { return TokenRecord{}, err } revoked, err := s.store.RevokeToken(ctx, principalID, tokenRef, revision) if err != nil { return TokenRecord{}, mapStoreError(err) } return tokenRecordFromToken(revoked), nil } // slotRecordFromSlot converts a credentialstore.CredentialSlotRecord to a // secret-blind SlotRecord DTO. func slotRecordFromSlot(s *credentialstore.CredentialSlotRecord) SlotRecord { var r SlotRecord r.ID = s.ID r.Vendor = s.Vendor r.CredentialKind = s.CredentialKind r.Alias = s.Alias r.Status = s.Status r.Revision = s.Revision r.CreatedAt = s.CreatedAt r.UpdatedAt = s.UpdatedAt r.RevokedAt = s.RevokedAt return r } // routeRecordFromRoute converts a credentialstore.RouteRecord to a DTO. func routeRecordFromRoute(r *credentialstore.RouteRecord) RouteRecord { return RouteRecord{ ID: r.ID, SlotID: r.SlotID, Alias: r.Alias, ProfileID: r.ProfileID, UpstreamModel: r.UpstreamModel, ResourceSelector: r.ResourceSelector, Status: r.Status, Revision: r.Revision, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, RevokedAt: r.RevokedAt, } } // tokenRecordFromToken converts a credentialstore.TokenRecord to a DTO. func tokenRecordFromToken(t *credentialstore.TokenRecord) TokenRecord { return TokenRecord{ ID: t.ID, TokenRef: t.TokenRef, Status: t.Status, Revision: t.Revision, CreatedAt: t.CreatedAt, UpdatedAt: t.UpdatedAt, RevokedAt: t.RevokedAt, } } // mapStoreError translates credentialstore errors into credentialops // typed errors so callers do not depend on the store package. func mapStoreError(err error) error { if err == nil { return nil } if errors.Is(err, credentialstore.ErrRevisionMismatch) { return ErrStaleRevision } if errors.Is(err, credentialstore.ErrSlotNotFound) || errors.Is(err, credentialstore.ErrRouteNotFound) { return ErrNotFound } if errors.Is(err, credentialstore.ErrCrossPrincipalSlot) { return ErrUnauthorized } if errors.Is(err, credentialstore.ErrTokenNotFound) || errors.Is(err, credentialstore.ErrPrincipalNotFound) { return ErrNotFound } if errors.Is(err, credentialstore.ErrInvalidSecretEnvelope) || errors.Is(err, credentialstore.ErrEnvelopeKeyUnavailable) || errors.Is(err, credentialstore.ErrUnknownEnvelopeKey) { return ErrSecretHandlingFailed } if errors.Is(err, credentialstore.ErrTokenRevoked) || errors.Is(err, credentialstore.ErrTokenNotActive) { return fmt.Errorf("%w: %v", ErrUnauthorized, err) } if errors.Is(err, credentialstore.ErrSlotRevoked) || errors.Is(err, credentialstore.ErrRouteRevoked) { return fmt.Errorf("%w: %v", ErrUnauthorized, err) } if errors.Is(err, credentialstore.ErrSlotNotActive) || errors.Is(err, credentialstore.ErrRouteNotActive) { return fmt.Errorf("%w: %v", ErrUnauthorized, err) } return err } // sha256Hex returns the lowercase hex SHA-256 digest of raw. func sha256Hex(raw []byte) string { sum := sha256.Sum256(raw) return hex.EncodeToString(sum[:]) } // canonicalCredentialKind normalizes and validates the caller-supplied // credential kind. It reuses the credentialstore constants so the value // written into the seal context and persisted in the slot row are identical, // preventing AAD divergence after restart. Unknown kinds are rejected before // any sealer or store call. func canonicalCredentialKind(raw string) (string, error) { norm := strings.ToLower(strings.TrimSpace(raw)) switch norm { case credentialstore.CredentialKindBearer, credentialstore.CredentialKindAPIKey: return norm, nil default: return "", fmt.Errorf("%w: %q", ErrInvalidCredentialKind, raw) } }