iop/packages/go/credentiallease/envelope.go

418 lines
13 KiB
Go

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