route 검증과 lease 발급이 같은 불변 profile snapshot을 사용해야 프로세스 내 호환 map 변경에도 유효한 credential route가 안정적으로 동작한다. 코드레벨 검토를 통과한 Milestone과 SDD도 완료 상태로 archive한다.
165 lines
5.9 KiB
Go
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
|
|
}
|
|
}
|