- ect library: create, verify, DAG validation, ledger interface - In-memory ledger and ECTStore for full ledger mode - Test vectors and unit tests; two-agent demo (cmd/demo) - README: document refimpl scope and usage Co-authored-by: Cursor <cursoragent@cursor.com>
119 lines
2.9 KiB
Go
119 lines
2.9 KiB
Go
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)
|
||
}
|