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 // IATMaxAge 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 // ValidateUUIDs when true requires jti and wid (if set) to be UUID format (RFC 9562). ValidateUUIDs bool // MaxPredLength is the max number of predecessor references (0 = no limit; recommended 100). MaxPredLength int } // 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). // Create may modify the payload in place (Iat, Exp, Sub, Par) when filling defaults; pass a copy if the original must stay unchanged. func Create(payload *Payload, privateKey *ecdsa.PrivateKey, opts CreateOptions) (compact string, err error) { if payload == nil || privateKey == nil { return "", ErrPayloadRequired } if opts.KeyID == "" { return "", ErrKeyIDRequired } 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.Pred == nil { payload.Pred = []string{} } if err := validatePayloadForCreate(payload, opts); 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, opts CreateOptions) 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.ExecAct == "" { return errors.New("ect: exec_act required") } if opts.ValidateUUIDs { if !ValidUUID(p.Jti) { return ErrInvalidJTI } if p.Wid != "" && !ValidUUID(p.Wid) { return ErrInvalidWID } } if opts.MaxPredLength > 0 && len(p.Pred) > opts.MaxPredLength { return ErrPredLength } if p.InpHash != "" { if err := ValidateHashFormat(p.InpHash); err != nil { return err } } if p.OutHash != "" { if err := ValidateHashFormat(p.OutHash); err != nil { return err } } if err := ValidateExt(p.Ext); err != nil { return err } // compensation_* live in ext per spec if p.Ext != nil { if _, hasReason := p.Ext["compensation_reason"]; hasReason { if v, _ := p.Ext["compensation_required"].(bool); !v { return errors.New("ect: ext.compensation_reason requires ext.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) }