iop/apps/control-plane/internal/credentiallease/service.go
toki ef5b3e7557 fix(credential): lease 프로필 카탈로그를 고정한다
route 검증과 lease 발급이 같은 불변 profile snapshot을 사용해야 프로세스 내 호환 map 변경에도 유효한 credential route가 안정적으로 동작한다. 코드레벨 검토를 통과한 Milestone과 SDD도 완료 상태로 archive한다.
2026-08-02 09:58:57 +09:00

165 lines
5.9 KiB
Go

// 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
}
}