Add WIMSE ECT reference implementation (Go)
- 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>
This commit is contained in:
34
refimpl/ect/audience.go
Normal file
34
refimpl/ect/audience.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package ect
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
func marshalJSONString(s string) []byte {
|
||||
b, _ := json.Marshal(s)
|
||||
return b
|
||||
}
|
||||
|
||||
func marshalJSONStringArray(a []string) []byte {
|
||||
b, _ := json.Marshal(a)
|
||||
return b
|
||||
}
|
||||
|
||||
func unmarshalAudience(data []byte, a *Audience) error {
|
||||
var raw json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(raw) > 0 && raw[0] == '[' {
|
||||
var arr []string
|
||||
if err := json.Unmarshal(raw, &arr); err != nil {
|
||||
return err
|
||||
}
|
||||
*a = arr
|
||||
return nil
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
*a = []string{s}
|
||||
return nil
|
||||
}
|
||||
118
refimpl/ect/create.go
Normal file
118
refimpl/ect/create.go
Normal file
@@ -0,0 +1,118 @@
|
||||
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)
|
||||
}
|
||||
99
refimpl/ect/create_test.go
Normal file
99
refimpl/ect/create_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package ect
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCreateRoundtrip(t *testing.T) {
|
||||
key, err := GenerateKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now()
|
||||
payload := &Payload{
|
||||
Iss: "spiffe://example.com/agent/a",
|
||||
Aud: []string{"spiffe://example.com/agent/b"},
|
||||
Iat: now.Unix(),
|
||||
Exp: now.Add(10 * time.Minute).Unix(),
|
||||
Jti: "e4f5a6b7-c8d9-0123-ef01-234567890abc",
|
||||
Tid: "550e8400-e29b-41d4-a716-446655440001",
|
||||
ExecAct: "review_spec",
|
||||
Par: []string{},
|
||||
Pol: "spec_review_policy_v2",
|
||||
PolDecision: PolDecisionApproved,
|
||||
}
|
||||
compact, err := Create(payload, key, CreateOptions{KeyID: "agent-a-key-1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if compact == "" {
|
||||
t.Fatal("expected non-empty compact JWS")
|
||||
}
|
||||
|
||||
// Verify with same key
|
||||
resolver := func(kid string) (*ecdsa.PublicKey, error) {
|
||||
if kid != "agent-a-key-1" {
|
||||
return nil, nil
|
||||
}
|
||||
return &key.PublicKey, nil
|
||||
}
|
||||
opts := VerifyOptions{
|
||||
VerifierID: "spiffe://example.com/agent/b",
|
||||
ResolveKey: resolver,
|
||||
Now: now,
|
||||
IATMaxAge: 15 * time.Minute,
|
||||
IATMaxFuture: 30 * time.Second,
|
||||
}
|
||||
parsed, err := Verify(compact, opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parsed.Payload.Tid != payload.Tid || parsed.Payload.ExecAct != payload.ExecAct {
|
||||
t.Errorf("payload mismatch: got tid=%q exec_act=%q", parsed.Payload.Tid, parsed.Payload.ExecAct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateWithTestVector(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/valid_root_ect_payload.json")
|
||||
if err != nil {
|
||||
t.Skipf("test vector not found: %v", err)
|
||||
return
|
||||
}
|
||||
var p Payload
|
||||
if err := json.Unmarshal(data, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, err := GenerateKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Override timestamps for verification
|
||||
now := time.Now()
|
||||
p.Iat = now.Unix()
|
||||
p.Exp = now.Add(10 * time.Minute).Unix()
|
||||
|
||||
compact, err := Create(&p, key, CreateOptions{KeyID: "test-kid"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolver := func(kid string) (*ecdsa.PublicKey, error) {
|
||||
if kid != "test-kid" {
|
||||
return nil, nil
|
||||
}
|
||||
return &key.PublicKey, nil
|
||||
}
|
||||
_, err = Verify(compact, VerifyOptions{
|
||||
VerifierID: p.Aud[0],
|
||||
ResolveKey: resolver,
|
||||
Now: now,
|
||||
IATMaxAge: 15 * time.Minute,
|
||||
IATMaxFuture: 30 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
108
refimpl/ect/dag.go
Normal file
108
refimpl/ect/dag.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package ect
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// DefaultClockSkewTolerance is the recommended clock skew between agents (Section 6.2).
|
||||
const DefaultClockSkewTolerance = 30 // seconds
|
||||
|
||||
// DefaultMaxAncestorLimit is the recommended max ancestor traversal for cycle detection (Section 6.3).
|
||||
const DefaultMaxAncestorLimit = 10000
|
||||
|
||||
// ECTStore provides lookup of ECTs by task ID (and optionally workflow ID) for DAG validation.
|
||||
// Implemented by ledger or in-memory cache of verified parent ECTs.
|
||||
type ECTStore interface {
|
||||
// GetByTid returns the payload for the given task ID, or nil if not found.
|
||||
GetByTid(tid string) *Payload
|
||||
// Contains returns true if (tid, wid) already exists. wid may be empty for global scope.
|
||||
Contains(tid, wid string) bool
|
||||
}
|
||||
|
||||
// DAGConfig holds parameters for DAG validation.
|
||||
type DAGConfig struct {
|
||||
ClockSkewTolerance int // seconds; recommended 30
|
||||
MaxAncestorLimit int // recommended 10000
|
||||
}
|
||||
|
||||
// DefaultDAGConfig returns recommended defaults.
|
||||
func DefaultDAGConfig() DAGConfig {
|
||||
return DAGConfig{
|
||||
ClockSkewTolerance: DefaultClockSkewTolerance,
|
||||
MaxAncestorLimit: DefaultMaxAncestorLimit,
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateDAG runs Section 6.2 validation rules: uniqueness, parent existence,
|
||||
// temporal ordering, acyclicity, parent policy decision.
|
||||
func ValidateDAG(ect *Payload, store ECTStore, cfg DAGConfig) error {
|
||||
if store == nil {
|
||||
return errors.New("ect: ECTStore required for DAG validation")
|
||||
}
|
||||
if cfg.ClockSkewTolerance <= 0 {
|
||||
cfg.ClockSkewTolerance = DefaultClockSkewTolerance
|
||||
}
|
||||
if cfg.MaxAncestorLimit <= 0 {
|
||||
cfg.MaxAncestorLimit = DefaultMaxAncestorLimit
|
||||
}
|
||||
|
||||
// 1. Task ID Uniqueness
|
||||
if store.Contains(ect.Tid, ect.Wid) {
|
||||
return fmt.Errorf("ect: task ID already exists: %s", ect.Tid)
|
||||
}
|
||||
|
||||
// 2. Parent Existence and 3. Temporal Ordering
|
||||
for _, parentID := range ect.Par {
|
||||
parent := store.GetByTid(parentID)
|
||||
if parent == nil {
|
||||
return fmt.Errorf("ect: parent task not found: %s", parentID)
|
||||
}
|
||||
// parent.iat < child.iat + clock_skew_tolerance => parent.iat - ect.iat <= clock_skew_tolerance
|
||||
if parent.Iat >= ect.Iat+int64(cfg.ClockSkewTolerance) {
|
||||
return fmt.Errorf("ect: parent task not earlier than current: %s", parentID)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Acyclicity (and depth limit)
|
||||
visited := make(map[string]struct{})
|
||||
if hasCycle(ect.Tid, ect.Par, store, visited, cfg.MaxAncestorLimit) {
|
||||
return errors.New("ect: circular dependency or depth limit exceeded")
|
||||
}
|
||||
|
||||
// 5. Parent Policy Decision
|
||||
for _, parentID := range ect.Par {
|
||||
parent := store.GetByTid(parentID)
|
||||
if parent.PolDecision == PolDecisionRejected || parent.PolDecision == PolDecisionPendingHumanReview {
|
||||
if !ect.CompensationRequired {
|
||||
return errors.New("ect: parent has non-approved pol_decision; current ECT must be compensation/remediation or have compensation_required true")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasCycle returns true if following par from the given parent IDs leads back to targetTid
|
||||
// or if traversal exceeds maxDepth. visited is mutated.
|
||||
func hasCycle(targetTid string, parentIDs []string, store ECTStore, visited map[string]struct{}, maxDepth int) bool {
|
||||
if len(visited) >= maxDepth {
|
||||
return true
|
||||
}
|
||||
for _, parentID := range parentIDs {
|
||||
if parentID == targetTid {
|
||||
return true
|
||||
}
|
||||
if _, ok := visited[parentID]; ok {
|
||||
continue
|
||||
}
|
||||
visited[parentID] = struct{}{}
|
||||
parent := store.GetByTid(parentID)
|
||||
if parent != nil {
|
||||
if hasCycle(targetTid, parent.Par, store, visited, maxDepth) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
61
refimpl/ect/dag_test.go
Normal file
61
refimpl/ect/dag_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package ect
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestValidateDAG_Root(t *testing.T) {
|
||||
store := NewMemoryLedger()
|
||||
payload := &Payload{
|
||||
Tid: "task-001",
|
||||
Wid: "wf-1",
|
||||
Par: []string{},
|
||||
PolDecision: PolDecisionApproved,
|
||||
}
|
||||
err := ValidateDAG(payload, store, DefaultDAGConfig())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDAG_DuplicateTid(t *testing.T) {
|
||||
store := NewMemoryLedger()
|
||||
// Pre-insert same tid
|
||||
_, _ = store.Append("dummy-jws", &Payload{Tid: "task-001", Wid: "wf-1", Par: []string{}, PolDecision: PolDecisionApproved})
|
||||
payload := &Payload{Tid: "task-001", Wid: "wf-1", Par: []string{}, PolDecision: PolDecisionApproved}
|
||||
err := ValidateDAG(payload, store, DefaultDAGConfig())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate tid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDAG_ParentExists(t *testing.T) {
|
||||
store := NewMemoryLedger()
|
||||
_, _ = store.Append("jws1", &Payload{Tid: "task-001", Wid: "wf-1", Par: []string{}, PolDecision: PolDecisionApproved, Iat: time.Now().Unix() - 60})
|
||||
payload := &Payload{
|
||||
Tid: "task-002",
|
||||
Wid: "wf-1",
|
||||
Par: []string{"task-001"},
|
||||
PolDecision: PolDecisionApproved,
|
||||
Iat: time.Now().Unix(),
|
||||
}
|
||||
err := ValidateDAG(payload, store, DefaultDAGConfig())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDAG_ParentNotFound(t *testing.T) {
|
||||
store := NewMemoryLedger()
|
||||
payload := &Payload{
|
||||
Tid: "task-002",
|
||||
Par: []string{"task-missing"},
|
||||
PolDecision: PolDecisionApproved,
|
||||
Iat: time.Now().Unix(),
|
||||
}
|
||||
err := ValidateDAG(payload, store, DefaultDAGConfig())
|
||||
if err == nil {
|
||||
t.Fatal("expected error when parent not found")
|
||||
}
|
||||
}
|
||||
107
refimpl/ect/ledger.go
Normal file
107
refimpl/ect/ledger.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package ect
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LedgerEntry represents a single ECT record in the audit ledger (Section 9.3).
|
||||
type LedgerEntry struct {
|
||||
LedgerSequence int64 `json:"ledger_sequence"`
|
||||
TaskID string `json:"task_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
Action string `json:"action"`
|
||||
Parents []string `json:"parents"`
|
||||
ECTJWS string `json:"ect_jws"`
|
||||
SignatureVerified bool `json:"signature_verified"`
|
||||
VerificationTime time.Time `json:"verification_timestamp"`
|
||||
StoredTime time.Time `json:"stored_timestamp"`
|
||||
}
|
||||
|
||||
// Ledger is the audit ledger interface per Section 9. Append-only; lookup by tid.
|
||||
type Ledger interface {
|
||||
// Append records a verified ECT. Returns the new ledger sequence number or error.
|
||||
Append(ectJWS string, payload *Payload) (seq int64, err error)
|
||||
// GetByTid returns the payload for the given task ID, or nil.
|
||||
GetByTid(tid string) *Payload
|
||||
// Contains returns true if (tid, wid) exists. wid may be empty for global scope.
|
||||
Contains(tid, wid string) bool
|
||||
// ECTStore implementation for DAG validation
|
||||
ECTStore
|
||||
}
|
||||
|
||||
// MemoryLedger is an in-memory, append-only ECT store implementing Ledger and ECTStore.
|
||||
type MemoryLedger struct {
|
||||
mu sync.RWMutex
|
||||
seq int64
|
||||
byTid map[string]*Payload
|
||||
bySeq []LedgerEntry
|
||||
entries []LedgerEntry // full entries for audit
|
||||
}
|
||||
|
||||
// NewMemoryLedger creates an empty in-memory ledger.
|
||||
func NewMemoryLedger() *MemoryLedger {
|
||||
return &MemoryLedger{
|
||||
byTid: make(map[string]*Payload),
|
||||
bySeq: make([]LedgerEntry, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// Append implements Ledger.
|
||||
func (m *MemoryLedger) Append(ectJWS string, payload *Payload) (int64, error) {
|
||||
if payload == nil {
|
||||
return 0, nil
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
// Uniqueness: tid must not already exist (scoped by wid when present)
|
||||
wid := payload.Wid
|
||||
if m.containsLocked(payload.Tid, wid) {
|
||||
return 0, ErrTaskIDExists
|
||||
}
|
||||
m.seq++
|
||||
entry := LedgerEntry{
|
||||
LedgerSequence: m.seq,
|
||||
TaskID: payload.Tid,
|
||||
AgentID: payload.Iss,
|
||||
Action: payload.ExecAct,
|
||||
Parents: append([]string(nil), payload.Par...),
|
||||
ECTJWS: ectJWS,
|
||||
SignatureVerified: true,
|
||||
VerificationTime: time.Now().UTC(),
|
||||
StoredTime: time.Now().UTC(),
|
||||
}
|
||||
m.byTid[payload.Tid] = payload
|
||||
m.bySeq = append(m.bySeq, entry)
|
||||
m.entries = append(m.entries, entry)
|
||||
return m.seq, nil
|
||||
}
|
||||
|
||||
// GetByTid implements ECTStore and Ledger.
|
||||
func (m *MemoryLedger) GetByTid(tid string) *Payload {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.byTid[tid]
|
||||
}
|
||||
|
||||
// Contains implements ECTStore and Ledger.
|
||||
func (m *MemoryLedger) Contains(tid, wid string) bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.containsLocked(tid, wid)
|
||||
}
|
||||
|
||||
func (m *MemoryLedger) containsLocked(tid, wid string) bool {
|
||||
p, ok := m.byTid[tid]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if wid == "" {
|
||||
return true
|
||||
}
|
||||
return p.Wid == wid
|
||||
}
|
||||
|
||||
// ErrTaskIDExists is returned when appending an ECT whose tid already exists.
|
||||
var ErrTaskIDExists = errors.New("ect: task ID already exists in ledger")
|
||||
97
refimpl/ect/types.go
Normal file
97
refimpl/ect/types.go
Normal file
@@ -0,0 +1,97 @@
|
||||
// Package ect implements Execution Context Tokens (ECTs) per
|
||||
// draft-nennemann-wimse-execution-context-00.
|
||||
package ect
|
||||
|
||||
import "time"
|
||||
|
||||
// ECTType is the JOSE typ value for ECTs.
|
||||
const ECTType = "wimse-exec+jwt"
|
||||
|
||||
// PolDecision values per Section 4.2.3.
|
||||
const (
|
||||
PolDecisionApproved = "approved"
|
||||
PolDecisionRejected = "rejected"
|
||||
PolDecisionPendingHumanReview = "pending_human_review"
|
||||
)
|
||||
|
||||
// Payload holds ECT JWT claims per Section 4.2.
|
||||
type Payload struct {
|
||||
// Standard JWT claims (required unless noted)
|
||||
Iss string `json:"iss"` // REQUIRED: issuer, SPIFFE ID
|
||||
Sub string `json:"sub,omitempty"`
|
||||
Aud Audience `json:"aud"` // REQUIRED
|
||||
Iat int64 `json:"iat"` // REQUIRED: NumericDate
|
||||
Exp int64 `json:"exp"` // REQUIRED
|
||||
Jti string `json:"jti"` // REQUIRED: UUID
|
||||
|
||||
// Execution context (Section 4.2.2)
|
||||
Wid string `json:"wid,omitempty"` // OPTIONAL: workflow ID, UUID
|
||||
Tid string `json:"tid"` // REQUIRED: task ID, UUID
|
||||
ExecAct string `json:"exec_act"` // REQUIRED
|
||||
Par []string `json:"par"` // REQUIRED: parent task IDs
|
||||
|
||||
// Policy evaluation (Section 4.2.3)
|
||||
Pol string `json:"pol"` // REQUIRED
|
||||
PolDecision string `json:"pol_decision"` // REQUIRED: approved | rejected | pending_human_review
|
||||
PolEnforcer string `json:"pol_enforcer,omitempty"` // OPTIONAL
|
||||
PolTimestamp int64 `json:"pol_timestamp,omitempty"` // OPTIONAL
|
||||
|
||||
// Data integrity (Section 4.2.4)
|
||||
InpHash string `json:"inp_hash,omitempty"`
|
||||
OutHash string `json:"out_hash,omitempty"`
|
||||
InpClassification string `json:"inp_classification,omitempty"`
|
||||
|
||||
// Task metadata (Section 4.2.5)
|
||||
ExecTimeMs int `json:"exec_time_ms,omitempty"`
|
||||
RegulatedDomain string `json:"regulated_domain,omitempty"`
|
||||
ModelVersion string `json:"model_version,omitempty"`
|
||||
WitnessedBy []string `json:"witnessed_by,omitempty"`
|
||||
|
||||
// Compensation (Section 4.2.6)
|
||||
CompensationRequired bool `json:"compensation_required,omitempty"`
|
||||
CompensationReason string `json:"compensation_reason,omitempty"`
|
||||
|
||||
// Extensions (Section 4.2.7)
|
||||
Ext map[string]interface{} `json:"ext,omitempty"`
|
||||
}
|
||||
|
||||
// Audience is aud claim: string or array of strings.
|
||||
type Audience []string
|
||||
|
||||
// MarshalJSON encodes aud as single string if one element, else array.
|
||||
func (a Audience) MarshalJSON() ([]byte, error) {
|
||||
if len(a) == 1 {
|
||||
return marshalJSONString(a[0]), nil
|
||||
}
|
||||
return marshalJSONStringArray(a), nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes aud from string or array.
|
||||
func (a *Audience) UnmarshalJSON(data []byte) error {
|
||||
return unmarshalAudience(data, a)
|
||||
}
|
||||
|
||||
// ValidPolDecision returns true if s is a registered pol_decision value.
|
||||
func ValidPolDecision(s string) bool {
|
||||
return s == PolDecisionApproved || s == PolDecisionRejected || s == PolDecisionPendingHumanReview
|
||||
}
|
||||
|
||||
// ContainsAudience returns true if verifierID is in the audience.
|
||||
func (p *Payload) ContainsAudience(verifierID string) bool {
|
||||
for _, id := range p.Aud {
|
||||
if id == verifierID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IATTime returns p.Iat as time.Time.
|
||||
func (p *Payload) IATTime() time.Time {
|
||||
return time.Unix(p.Iat, 0)
|
||||
}
|
||||
|
||||
// ExpTime returns p.Exp as time.Time.
|
||||
func (p *Payload) ExpTime() time.Time {
|
||||
return time.Unix(p.Exp, 0)
|
||||
}
|
||||
195
refimpl/ect/verify.go
Normal file
195
refimpl/ect/verify.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package ect
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
)
|
||||
|
||||
// ParsedECT holds a verified or parsed ECT (header + payload).
|
||||
type ParsedECT struct {
|
||||
Header *jose.Header
|
||||
Payload *Payload
|
||||
Raw string // compact JWS
|
||||
}
|
||||
|
||||
// KeyResolver returns the public key for a given kid, or nil if unknown/revoked.
|
||||
// Returning (nil, nil) means key not found; (nil, err) means lookup error.
|
||||
type KeyResolver func(kid string) (*ecdsa.PublicKey, error)
|
||||
|
||||
// VerifyOptions configures ECT verification per Section 7.
|
||||
type VerifyOptions struct {
|
||||
// VerifierID is the workload identity of the verifier (must be in aud).
|
||||
VerifierID string
|
||||
// ResolveKey returns the public key for kid. Required.
|
||||
ResolveKey KeyResolver
|
||||
// ECTStore for DAG validation. If nil, DAG validation is skipped (e.g. first hop or point-to-point without ledger).
|
||||
Store ECTStore
|
||||
// DAGConfig for DAG validation. Used only if Store != nil.
|
||||
DAG DAGConfig
|
||||
// Now is the current time; if zero, time.Now() is used.
|
||||
Now time.Time
|
||||
// IATMaxAge is max age of iat (recommended 15 min). If zero, 15 minutes.
|
||||
IATMaxAge time.Duration
|
||||
// IATMaxFuture is max clock skew for iat in future (recommended 30 sec). If zero, 30 seconds.
|
||||
IATMaxFuture time.Duration
|
||||
// JTISeen returns true if jti was already seen (replay). If nil, replay check is skipped.
|
||||
JTISeen func(jti string) bool
|
||||
// WITSubject if set must equal payload.iss (issuer must match WIT subject for this key).
|
||||
WITSubject string
|
||||
}
|
||||
|
||||
// DefaultVerifyOptions returns recommended defaults.
|
||||
func DefaultVerifyOptions() VerifyOptions {
|
||||
return VerifyOptions{
|
||||
IATMaxAge: 15 * time.Minute,
|
||||
IATMaxFuture: 30 * time.Second,
|
||||
DAG: DefaultDAGConfig(),
|
||||
}
|
||||
}
|
||||
|
||||
// Parse parses compact JWS and returns header + payload without cryptographic verification.
|
||||
// Use Verify for full signature and claim validation.
|
||||
func Parse(compact string) (*ParsedECT, error) {
|
||||
jws, err := jose.ParseSigned(compact, []jose.SignatureAlgorithm{jose.ES256})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(jws.Signatures) != 1 {
|
||||
return nil, errors.New("ect: expected single signature")
|
||||
}
|
||||
sig := jws.Signatures[0]
|
||||
payloadBytes := jws.UnsafePayloadWithoutVerification()
|
||||
if len(payloadBytes) == 0 {
|
||||
return nil, errors.New("ect: empty payload")
|
||||
}
|
||||
var p Payload
|
||||
if err := json.Unmarshal(payloadBytes, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ParsedECT{
|
||||
Header: &sig.Header,
|
||||
Payload: &p,
|
||||
Raw: compact,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Verify performs full Section 7 verification and optional DAG validation.
|
||||
func Verify(compact string, opts VerifyOptions) (*ParsedECT, error) {
|
||||
jws, err := jose.ParseSigned(compact, []jose.SignatureAlgorithm{jose.ES256})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(jws.Signatures) != 1 {
|
||||
return nil, errors.New("ect: expected single signature")
|
||||
}
|
||||
sig := jws.Signatures[0]
|
||||
header := &sig.Header
|
||||
|
||||
// 1. Parse JWS — done
|
||||
|
||||
// 2. typ must be wimse-exec+jwt
|
||||
typ, _ := header.ExtraHeaders["typ"].(string)
|
||||
if typ == "" {
|
||||
// try standard typ
|
||||
typ = header.ExtraHeaders[jose.HeaderType].(string)
|
||||
}
|
||||
if typ != ECTType {
|
||||
return nil, errors.New("ect: invalid typ parameter")
|
||||
}
|
||||
|
||||
// 3. alg not none and not symmetric
|
||||
if header.Algorithm == "none" || header.Algorithm == "HS256" || header.Algorithm == "HS384" || header.Algorithm == "HS512" {
|
||||
return nil, errors.New("ect: prohibited algorithm")
|
||||
}
|
||||
|
||||
// 4. kid references known key
|
||||
kid := header.KeyID
|
||||
if kid == "" {
|
||||
return nil, errors.New("ect: missing kid")
|
||||
}
|
||||
pub, err := opts.ResolveKey(kid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ect: key resolution: %w", err)
|
||||
}
|
||||
if pub == nil {
|
||||
return nil, errors.New("ect: unknown key identifier")
|
||||
}
|
||||
|
||||
// 5. Verify JWS signature
|
||||
payloadBytes, err := jws.Verify(&jose.JSONWebKey{Key: pub, KeyID: kid})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ect: invalid signature: %w", err)
|
||||
}
|
||||
|
||||
var p Payload
|
||||
if err := json.Unmarshal(payloadBytes, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 6. Key revocation — caller's ResolveKey can return nil for revoked
|
||||
// 7. alg match WIT — we don't have WIT; optional WITSubject check below
|
||||
// 8. iss matches WIT subject
|
||||
if opts.WITSubject != "" && p.Iss != opts.WITSubject {
|
||||
return nil, errors.New("ect: issuer does not match WIT subject")
|
||||
}
|
||||
|
||||
// 9. aud contains verifier
|
||||
if opts.VerifierID != "" && !p.ContainsAudience(opts.VerifierID) {
|
||||
return nil, errors.New("ect: audience does not include verifier")
|
||||
}
|
||||
|
||||
// 10. exp not expired
|
||||
now := opts.Now
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
if now.Unix() > p.Exp {
|
||||
return nil, errors.New("ect: token expired")
|
||||
}
|
||||
|
||||
// 11. iat not too far past/future
|
||||
if opts.IATMaxAge == 0 {
|
||||
opts.IATMaxAge = 15 * time.Minute
|
||||
}
|
||||
if opts.IATMaxFuture == 0 {
|
||||
opts.IATMaxFuture = 30 * time.Second
|
||||
}
|
||||
if now.Unix()-p.Iat > int64(opts.IATMaxAge.Seconds()) {
|
||||
return nil, errors.New("ect: iat too far in the past")
|
||||
}
|
||||
if p.Iat > now.Unix()+int64(opts.IATMaxFuture.Seconds()) {
|
||||
return nil, errors.New("ect: iat in the future")
|
||||
}
|
||||
|
||||
// 12. Required claims present
|
||||
if p.Jti == "" || p.Tid == "" || p.ExecAct == "" || p.Pol == "" || p.PolDecision == "" {
|
||||
return nil, errors.New("ect: missing required claims")
|
||||
}
|
||||
if p.Par == nil {
|
||||
p.Par = []string{}
|
||||
}
|
||||
|
||||
// 13. pol_decision in registry
|
||||
if !ValidPolDecision(p.PolDecision) {
|
||||
return nil, errors.New("ect: invalid pol_decision value")
|
||||
}
|
||||
|
||||
// 14. DAG validation
|
||||
if opts.Store != nil {
|
||||
if err := ValidateDAG(&p, opts.Store, opts.DAG); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// 15. Replay (jti seen)
|
||||
if opts.JTISeen != nil && opts.JTISeen(p.Jti) {
|
||||
return nil, errors.New("ect: jti already seen (replay)")
|
||||
}
|
||||
|
||||
return &ParsedECT{Header: header, Payload: &p, Raw: compact}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user