// Package credentiallease implements the bounded, signed, recipient-sealed // credential lease used between the Control Plane, Edge, and Node. package credentiallease import ( "bytes" "context" "crypto/aes" "crypto/cipher" "crypto/ecdh" "crypto/ed25519" "crypto/hkdf" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/binary" "errors" "io" "net/http" "os" "path/filepath" "strings" "sync" "time" ) const ( Version = 1 MinTTL = 5 * time.Second MaxTTL = 300 * time.Second MinSetSize = 1 MaxSetSize = 4096 MaxCanonicalFieldSize = 4096 MaxCiphertextSize = 64 << 10 canonicalDomain = "iop.credential-lease.scope.v1" sealDomain = "iop.credential-lease.seal.v1" signDomain = "iop.credential-lease.signature.v1" ) var ( ErrInvalid = errors.New("credentiallease: invalid lease") ErrExpired = errors.New("credentiallease: expired lease") ErrScopeMismatch = errors.New("credentiallease: scope mismatch") ErrReplay = errors.New("credentiallease: replay rejected") ErrOverflow = errors.New("credentiallease: live set full") ErrKey = errors.New("credentiallease: key unavailable") ) // Scope binds a lease to one exact authenticated route, selected Node, target, // projection generation, and credential revision. HeaderName and Scheme are // signed profile-auth instructions and never carry secret bytes. type Scope struct { LeaseID string PrincipalRef string CredentialSlotRef string RouteID string ProfileID string UpstreamTarget string NodeID string RecipientKeyID string HeaderName string Scheme string CredentialRevision uint64 RouteRevision uint64 ProjectionGeneration uint64 IssuedAtUnixNano int64 ExpiresAtUnixNano int64 } // Envelope contains no plaintext provider credential. Ciphertext is sealed to // one Node X25519 key and the complete value is signed by the Control Plane's // dedicated Ed25519 issuer key. type Envelope struct { Version uint32 IssuerKeyID string Scope Scope EphemeralPublicKey []byte Nonce []byte Ciphertext []byte Signature []byte } // ExpectedScope is supplied independently by the Node request boundary. Zero // revision/generation fields are still exact values, not wildcards. type ExpectedScope struct { PrincipalRef string CredentialSlotRef string RouteID string ProfileID string UpstreamTarget string NodeID string RecipientKeyID string CredentialRevision uint64 RouteRevision uint64 ProjectionGeneration uint64 } // Material is request-local plaintext returned only after atomic validation // and replay consumption. The caller must call Zero on every terminal path. type Material struct { HeaderName string Scheme string Secret []byte } func (m *Material) Zero() { if m == nil { return } zero(m.Secret) m.Secret = nil } // Issue seals secret to recipientPublic and signs the complete envelope. func Issue(scope Scope, secret, recipientPublic []byte, issuerKeyID string, issuerPrivate ed25519.PrivateKey, random io.Reader) (*Envelope, error) { if err := validateScope(scope, time.Time{}); err != nil { return nil, err } if len(secret) == 0 || len(secret) > MaxCiphertextSize || strings.TrimSpace(issuerKeyID) == "" || len(issuerPrivate) != ed25519.PrivateKeySize { return nil, ErrInvalid } curve := ecdh.X25519() recipient, err := curve.NewPublicKey(recipientPublic) if err != nil { return nil, ErrKey } if random == nil { random = rand.Reader } ephemeral, err := curve.GenerateKey(random) if err != nil { return nil, ErrKey } shared, err := ephemeral.ECDH(recipient) if err != nil { return nil, ErrKey } defer zero(shared) canonical := canonicalScope(scope) defer zero(canonical) key, err := hkdf.Key(sha256.New, shared, canonical, sealDomain, 32) if err != nil { return nil, ErrKey } defer zero(key) block, err := aes.NewCipher(key) if err != nil { return nil, ErrKey } aead, err := cipher.NewGCM(block) if err != nil { return nil, ErrKey } nonce := make([]byte, aead.NonceSize()) if _, err := io.ReadFull(random, nonce); err != nil { return nil, ErrKey } env := &Envelope{ Version: Version, IssuerKeyID: strings.TrimSpace(issuerKeyID), Scope: scope, EphemeralPublicKey: append([]byte(nil), ephemeral.PublicKey().Bytes()...), Nonce: nonce, } env.Ciphertext = aead.Seal(nil, env.Nonce, secret, canonical) env.Signature = ed25519.Sign(issuerPrivate, canonicalEnvelope(env, false)) return env, nil } // Consumer validates, atomically consumes, and decrypts a lease. Its replay // fence prunes only expired IDs and fails closed when every entry is live. type Consumer struct { mu sync.Mutex nodeID string recipientKeyID string recipientPrivate *ecdh.PrivateKey issuerKeyID string issuerPublic ed25519.PublicKey maxReplay int clock func() time.Time replay map[string]int64 } func NewConsumer(nodeID, recipientKeyID string, recipientPrivate []byte, issuerKeyID string, issuerPublic ed25519.PublicKey, maxReplay int, clock func() time.Time) (*Consumer, error) { if maxReplay < MinSetSize || maxReplay > MaxSetSize || strings.TrimSpace(nodeID) == "" || strings.TrimSpace(recipientKeyID) == "" || strings.TrimSpace(issuerKeyID) == "" || len(issuerPublic) != ed25519.PublicKeySize { return nil, ErrInvalid } privateKey, err := ecdh.X25519().NewPrivateKey(recipientPrivate) if err != nil { return nil, ErrKey } if clock == nil { clock = time.Now } return &Consumer{ nodeID: nodeID, recipientKeyID: recipientKeyID, recipientPrivate: privateKey, issuerKeyID: issuerKeyID, issuerPublic: append(ed25519.PublicKey(nil), issuerPublic...), maxReplay: maxReplay, clock: clock, replay: make(map[string]int64), }, nil } // RecipientPublicKey returns a fresh copy suitable for authenticated Node // registration. Private key material never leaves the Consumer. func (c *Consumer) RecipientPublicKey() []byte { if c == nil || c.recipientPrivate == nil { return nil } return append([]byte(nil), c.recipientPrivate.PublicKey().Bytes()...) } func (c *Consumer) RecipientKeyID() string { if c == nil { return "" } return c.recipientKeyID } func (c *Consumer) Consume(_ context.Context, env *Envelope, expected ExpectedScope) (*Material, error) { if c == nil || env == nil { return nil, ErrInvalid } c.mu.Lock() defer c.mu.Unlock() now := c.clock().UTC() if err := validateEnvelope(env, now, c.issuerKeyID, c.issuerPublic); err != nil { return nil, err } if err := c.match(env.Scope, expected); err != nil { return nil, err } for id, expiry := range c.replay { if expiry <= now.UnixNano() { delete(c.replay, id) } } if _, exists := c.replay[env.Scope.LeaseID]; exists { return nil, ErrReplay } if len(c.replay) >= c.maxReplay { return nil, ErrOverflow } ephemeral, err := ecdh.X25519().NewPublicKey(env.EphemeralPublicKey) if err != nil { return nil, ErrInvalid } shared, err := c.recipientPrivate.ECDH(ephemeral) if err != nil { return nil, ErrInvalid } defer zero(shared) canonical := canonicalScope(env.Scope) defer zero(canonical) key, err := hkdf.Key(sha256.New, shared, canonical, sealDomain, 32) if err != nil { return nil, ErrInvalid } defer zero(key) block, err := aes.NewCipher(key) if err != nil { return nil, ErrInvalid } aead, err := cipher.NewGCM(block) if err != nil || len(env.Nonce) != aead.NonceSize() { return nil, ErrInvalid } plaintext, err := aead.Open(nil, env.Nonce, env.Ciphertext, canonical) if err != nil { return nil, ErrInvalid } c.replay[env.Scope.LeaseID] = env.Scope.ExpiresAtUnixNano return &Material{HeaderName: env.Scope.HeaderName, Scheme: env.Scope.Scheme, Secret: plaintext}, nil } func (c *Consumer) match(scope Scope, expected ExpectedScope) error { if scope.NodeID != c.nodeID || scope.RecipientKeyID != c.recipientKeyID || scope.PrincipalRef != expected.PrincipalRef || scope.CredentialSlotRef != expected.CredentialSlotRef || scope.RouteID != expected.RouteID || scope.ProfileID != expected.ProfileID || scope.UpstreamTarget != expected.UpstreamTarget || scope.NodeID != expected.NodeID || scope.RecipientKeyID != expected.RecipientKeyID || scope.CredentialRevision != expected.CredentialRevision || scope.RouteRevision != expected.RouteRevision || scope.ProjectionGeneration != expected.ProjectionGeneration { return ErrScopeMismatch } return nil } func validateEnvelope(env *Envelope, now time.Time, issuerKeyID string, issuerPublic ed25519.PublicKey) error { if env.Version != Version || env.IssuerKeyID != issuerKeyID || len(env.EphemeralPublicKey) != 32 || len(env.Nonce) != 12 || len(env.Ciphertext) == 0 || len(env.Ciphertext) > MaxCiphertextSize+16 || len(env.Signature) != ed25519.SignatureSize { return ErrInvalid } if err := validateScope(env.Scope, now); err != nil { return err } if !ed25519.Verify(issuerPublic, canonicalEnvelope(env, false), env.Signature) { return ErrInvalid } return nil } func validateScope(scope Scope, now time.Time) error { for _, value := range []string{scope.LeaseID, scope.PrincipalRef, scope.CredentialSlotRef, scope.RouteID, scope.ProfileID, scope.UpstreamTarget, scope.NodeID, scope.RecipientKeyID, scope.HeaderName} { if strings.TrimSpace(value) == "" || len(value) > MaxCanonicalFieldSize { return ErrInvalid } } if http.CanonicalHeaderKey(scope.HeaderName) != scope.HeaderName || strings.ContainsAny(scope.HeaderName, "\r\n") || strings.ContainsAny(scope.Scheme, "\r\n") { return ErrInvalid } issued := time.Unix(0, scope.IssuedAtUnixNano) expires := time.Unix(0, scope.ExpiresAtUnixNano) ttl := expires.Sub(issued) if scope.IssuedAtUnixNano <= 0 || ttl < MinTTL || ttl > MaxTTL { return ErrInvalid } if !now.IsZero() && !now.Before(expires) { return ErrExpired } return nil } func canonicalScope(scope Scope) []byte { var b bytes.Buffer writeString(&b, canonicalDomain) writeString(&b, scope.LeaseID) writeString(&b, scope.PrincipalRef) writeString(&b, scope.CredentialSlotRef) writeString(&b, scope.RouteID) writeString(&b, scope.ProfileID) writeString(&b, scope.UpstreamTarget) writeString(&b, scope.NodeID) writeString(&b, scope.RecipientKeyID) writeString(&b, scope.HeaderName) writeString(&b, scope.Scheme) writeUint64(&b, scope.CredentialRevision) writeUint64(&b, scope.RouteRevision) writeUint64(&b, scope.ProjectionGeneration) writeUint64(&b, uint64(scope.IssuedAtUnixNano)) writeUint64(&b, uint64(scope.ExpiresAtUnixNano)) return b.Bytes() } func canonicalEnvelope(env *Envelope, includeSignature bool) []byte { var b bytes.Buffer writeString(&b, signDomain) writeUint64(&b, uint64(env.Version)) writeString(&b, env.IssuerKeyID) writeBytes(&b, canonicalScope(env.Scope)) writeBytes(&b, env.EphemeralPublicKey) writeBytes(&b, env.Nonce) writeBytes(&b, env.Ciphertext) if includeSignature { writeBytes(&b, env.Signature) } return b.Bytes() } func writeString(b *bytes.Buffer, value string) { writeBytes(b, []byte(value)) } func writeBytes(b *bytes.Buffer, value []byte) { writeUint64(b, uint64(len(value))) b.Write(value) } func writeUint64(b *bytes.Buffer, value uint64) { var raw [8]byte binary.BigEndian.PutUint64(raw[:], value) b.Write(raw[:]) } // LoadPrivateKeyFile loads a base64-encoded fixed-size private key from a // strict regular file. It is used for X25519 and Ed25519 deployment keys. func LoadPrivateKeyFile(path string, size int) ([]byte, error) { return loadKeyFile(path, size) } // LoadPublicKeyFile loads a base64-encoded fixed-size public key. Public files // need not be mode 0600, but must still be regular files. func LoadPublicKeyFile(path string, size int) ([]byte, error) { return readKeyFile(path, size, false) } func loadKeyFile(path string, size int) ([]byte, error) { return readKeyFile(path, size, true) } func readKeyFile(path string, size int, private bool) ([]byte, error) { resolved, err := filepath.EvalSymlinks(path) if err != nil { return nil, ErrKey } file, err := os.Open(resolved) if err != nil { return nil, ErrKey } defer file.Close() info, err := file.Stat() if err != nil || !info.Mode().IsRegular() { return nil, ErrKey } if private && !privateKeyFileSecure(file, info) { return nil, ErrKey } raw, err := io.ReadAll(io.LimitReader(file, int64(size*4+129))) if err != nil { return nil, ErrKey } defer zero(raw) trimmed := bytes.TrimSpace(raw) decoded := make([]byte, base64.StdEncoding.DecodedLen(len(trimmed))) written, err := base64.StdEncoding.Decode(decoded, trimmed) if err != nil || written != size { zero(decoded) return nil, ErrKey } return decoded[:written], nil } func zero(value []byte) { for i := range value { value[i] = 0 } }