package ect import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "encoding/json" "errors" "time" "github.com/go-jose/go-jose/v4" ) // CreateOptions configures ECT creation. type CreateOptions struct { // KeyID is the kid header value (references public key from WIT). KeyID string // NotBeforeIATWindow caps how far in the past iat may be (recommended 15 min). IATMaxAge time.Duration // DefaultExpiry is added to time.Now() for exp when zero (recommended 5–15 min). DefaultExpiry time.Duration } // DefaultCreateOptions returns recommended defaults. func DefaultCreateOptions() CreateOptions { return CreateOptions{ IATMaxAge: 15 * time.Minute, DefaultExpiry: 10 * time.Minute, } } // Create builds and signs an ECT. Payload must have required claims set; // Iat/Exp can be zero to use defaults (now, now+DefaultExpiry). func Create(payload *Payload, privateKey *ecdsa.PrivateKey, opts CreateOptions) (compact string, err error) { if payload == nil || privateKey == nil { return "", errors.New("ect: payload and privateKey required") } if opts.KeyID == "" { return "", errors.New("ect: KeyID required") } now := time.Now() if payload.Iat == 0 { payload.Iat = now.Unix() } if payload.Exp == 0 { if opts.DefaultExpiry == 0 { opts.DefaultExpiry = 10 * time.Minute } payload.Exp = now.Add(opts.DefaultExpiry).Unix() } if payload.Sub == "" { payload.Sub = payload.Iss } if payload.Par == nil { payload.Par = []string{} } if err := validatePayloadForCreate(payload); err != nil { return "", err } payloadBytes, err := json.Marshal(payload) if err != nil { return "", err } sig := jose.SigningKey{Algorithm: jose.ES256, Key: privateKey} options := &jose.SignerOptions{ ExtraHeaders: map[jose.HeaderKey]interface{}{ jose.HeaderType: ECTType, jose.HeaderKey("kid"): opts.KeyID, }, } signer, err := jose.NewSigner(sig, options) if err != nil { return "", err } jws, err := signer.Sign(payloadBytes) if err != nil { return "", err } return jws.CompactSerialize() } func validatePayloadForCreate(p *Payload) error { if p.Iss == "" { return errors.New("ect: iss required") } if len(p.Aud) == 0 { return errors.New("ect: aud required") } if p.Jti == "" { return errors.New("ect: jti required") } if p.Tid == "" { return errors.New("ect: tid required") } if p.ExecAct == "" { return errors.New("ect: exec_act required") } if p.Pol == "" { return errors.New("ect: pol required") } if !ValidPolDecision(p.PolDecision) { return errors.New("ect: pol_decision must be approved, rejected, or pending_human_review") } if p.CompensationReason != "" && !p.CompensationRequired { return errors.New("ect: compensation_reason requires compensation_required true") } return nil } // GenerateKey creates an ECDSA P-256 key for ES256 (for testing/demo). func GenerateKey() (*ecdsa.PrivateKey, error) { return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) }