// Package credentiallease issues short-lived provider credentials only after // checking the complete immutable dispatch binding against durable state. package credentiallease import ( "context" "crypto/ed25519" "crypto/rand" "errors" "fmt" "io" "net/http" "strings" "time" "github.com/google/uuid" "iop/apps/control-plane/internal/credentialseal" "iop/apps/control-plane/internal/credentialstore" "iop/packages/go/config" lease "iop/packages/go/credentiallease" iop "iop/proto/gen/iop" ) var ErrBindingRejected = errors.New("credential lease binding rejected") type opener interface { Open(context.Context, credentialstore.SecretEnvelope, credentialseal.Context) ([]byte, error) } type Service struct { store *credentialstore.Store opener opener protocolProfiles map[string]config.ProtocolProfileConf issuerKeyID string issuerKey ed25519.PrivateKey ttl time.Duration clock func() time.Time random io.Reader live chan struct{} } func New(store *credentialstore.Store, opener opener, issuerKeyID string, issuerKey ed25519.PrivateKey, ttl time.Duration, maxLive int, clock func() time.Time, randomSource io.Reader) (*Service, error) { if store == nil || opener == nil || strings.TrimSpace(issuerKeyID) == "" || len(issuerKey) != ed25519.PrivateKeySize || ttl < lease.MinTTL || ttl > lease.MaxTTL || maxLive < lease.MinSetSize || maxLive > lease.MaxSetSize { return nil, ErrBindingRejected } if clock == nil { clock = time.Now } if randomSource == nil { randomSource = rand.Reader } return &Service{ store: store, opener: opener, protocolProfiles: config.BuiltInProtocolProfileCatalog(), issuerKeyID: issuerKeyID, issuerKey: append(ed25519.PrivateKey(nil), issuerKey...), ttl: ttl, clock: clock, random: randomSource, live: make(chan struct{}, maxLive), }, nil } // Acquire re-reads generation, route, and slot before opening secret-at-rest. // Invalid or stale input therefore cannot trigger a keyring open. func (s *Service) Acquire(ctx context.Context, request *iop.AcquireLeaseRequest) (*iop.AcquireLeaseResponse, error) { if s == nil || request == nil || request.GetBinding() == nil || len(request.GetRecipientPublicKey()) != 32 { return rejected(), nil } b := request.GetBinding() if strings.TrimSpace(request.GetEdgeId()) == "" || !validBinding(b) { return rejected(), nil } select { case s.live <- struct{}{}: defer func() { <-s.live }() default: return rejected(), nil } generation, err := s.store.ProjectionGeneration(ctx) if err != nil || generation != b.GetProjectionGeneration() { return rejected(), nil } route, err := s.store.GetRoute(ctx, b.GetPrincipalRef(), b.GetRouteId()) if err != nil || route.Status != credentialstore.StatusActive || route.SlotID != b.GetCredentialSlotRef() || route.ProfileID != b.GetProfileId() || route.UpstreamModel != b.GetUpstreamTarget() || uint64(route.Revision) != b.GetRouteRevision() { return rejected(), nil } slot, err := s.store.GetSlot(ctx, b.GetPrincipalRef(), b.GetCredentialSlotRef()) if err != nil || slot.Status != credentialstore.StatusActive || uint64(slot.Revision) != b.GetCredentialRevision() { return rejected(), nil } profile, err := config.ResolveProtocolProfile(route.ProfileID, slot.Vendor, s.protocolProfiles) if err != nil || strings.TrimSpace(profile.Auth.Header) == "" { return rejected(), nil } plaintext, err := s.opener.Open(ctx, slot.Envelope, credentialseal.Context{PrincipalID: slot.PrincipalID, SlotID: slot.ID, Kind: slot.CredentialKind}) if err != nil { return rejected(), nil } defer zero(plaintext) leaseID, err := newLeaseID(s.random) if err != nil { return rejected(), nil } now := s.clock().UTC() // The built-in profile catalog may declare the auth header in canonical or // lowercase form (e.g. "x-api-key"). The signed lease scope only accepts the // canonical spelling, so normalize the trusted resolved header here before // issuing. validateScope stays strict for any directly supplied scope. headerName := http.CanonicalHeaderKey(profile.Auth.Header) envelope, err := lease.Issue(lease.Scope{ LeaseID: leaseID, PrincipalRef: b.GetPrincipalRef(), CredentialSlotRef: b.GetCredentialSlotRef(), RouteID: b.GetRouteId(), ProfileID: b.GetProfileId(), UpstreamTarget: b.GetUpstreamTarget(), NodeID: b.GetNodeId(), RecipientKeyID: b.GetRecipientKeyId(), HeaderName: headerName, Scheme: profile.Auth.Scheme, CredentialRevision: b.GetCredentialRevision(), RouteRevision: b.GetRouteRevision(), ProjectionGeneration: b.GetProjectionGeneration(), IssuedAtUnixNano: now.UnixNano(), ExpiresAtUnixNano: now.Add(s.ttl).UnixNano(), }, plaintext, request.GetRecipientPublicKey(), s.issuerKeyID, s.issuerKey, s.random) if err != nil { return rejected(), nil } return &iop.AcquireLeaseResponse{Lease: envelope.ToProto()}, nil } func newLeaseID(randomSource io.Reader) (string, error) { var raw [16]byte if _, err := io.ReadFull(randomSource, raw[:]); err != nil { return "", err } raw[6] = (raw[6] & 0x0f) | 0x40 raw[8] = (raw[8] & 0x3f) | 0x80 id, err := uuid.FromBytes(raw[:]) if err != nil { return "", err } return id.String(), nil } func validBinding(b *iop.CredentialLeaseBinding) bool { for _, value := range []string{b.GetPrincipalRef(), b.GetCredentialSlotRef(), b.GetRouteId(), b.GetProfileId(), b.GetUpstreamTarget(), b.GetNodeId(), b.GetRecipientKeyId()} { if strings.TrimSpace(value) == "" { return false } } return true } func rejected() *iop.AcquireLeaseResponse { return &iop.AcquireLeaseResponse{Error: ErrBindingRejected.Error()} } func LoadIssuerPrivateKey(path string) (ed25519.PrivateKey, error) { raw, err := lease.LoadPrivateKeyFile(path, ed25519.PrivateKeySize) if err != nil { return nil, fmt.Errorf("load credential lease issuer: %w", err) } return ed25519.PrivateKey(raw), nil } func zero(value []byte) { for i := range value { value[i] = 0 } }