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:
2026-02-24 22:05:30 +01:00
parent a263d8c201
commit f9357fdf88
14 changed files with 1065 additions and 0 deletions

View File

@@ -21,6 +21,7 @@ Target environments include medtech (FDA audit trails), finance (transaction rec
|------|-------------|
| `draft-nennemann-wimse-execution-context-00.md` | The Internet-Draft in kramdown-rfc markdown format |
| `master-prompt.md` | Design rationale, iteration plan, and reference material |
| `refimpl/` | **Reference implementation** (Go): ECT create/verify, DAG validation, in-memory ledger, and a two-agent demo. See `refimpl/README.md`. |
## Building the draft

97
refimpl/README.md Normal file
View File

@@ -0,0 +1,97 @@
# WIMSE Execution Context Tokens — Reference Implementation
This directory contains a **reference implementation** of [Execution Context Tokens (ECTs)](../draft-nennemann-wimse-execution-context-00.txt) for the WIMSE (Workload Identity in Multi System Environments) draft. It implements ECT creation, verification, DAG validation, and an in-memory audit ledger.
## Scope
- **ECT format**: JWT (JWS Compact Serialization) with required/optional claims per the spec (Section 4).
- **Creation**: Build and sign ECTs with ES256; `kid` and `typ: wimse-exec+jwt` in the JOSE header.
- **Verification**: Full Section 7 procedure (parse, typ/alg, key resolution, signature, claims, optional DAG).
- **DAG validation**: Section 6 (uniqueness, parent existence, temporal ordering, acyclicity, parent policy).
- **Ledger**: Interface plus in-memory append-only store (Section 9).
No WIT/WPT issuance or full WIMSE stack; the refimpl uses key resolution only. Suitable for conformance testing and as a template for production integrations.
## Layout
```
refimpl/
├── go.mod
├── README.md
├── ect/ # library
│ ├── types.go # Payload, Audience, constants
│ ├── audience.go # aud marshal/unmarshal
│ ├── create.go # Create(), GenerateKey()
│ ├── verify.go # Parse(), Verify(), VerifyOptions
│ ├── dag.go # ValidateDAG(), ECTStore
│ ├── ledger.go # Ledger, MemoryLedger
│ └── *_test.go
├── testdata/
│ └── valid_root_ect_payload.json
└── cmd/
└── demo/ # two-agent workflow demo
└── main.go
```
## Usage
### Library
```go
import "github.com/nennemann/ect-refimpl/ect"
// Create
key, _ := ect.GenerateKey()
payload := &ect.Payload{
Iss: "spiffe://example.com/agent/a",
Aud: ect.Audience{"spiffe://example.com/agent/b"},
Iat: time.Now().Unix(),
Exp: time.Now().Add(10*time.Minute).Unix(),
Jti: "uuid-...",
Tid: "task-001",
ExecAct: "review_spec",
Par: []string{},
Pol: "policy_v1",
PolDecision: ect.PolDecisionApproved,
}
compact, err := ect.Create(payload, key, ect.CreateOptions{KeyID: "agent-a-key"})
// Verify (with DAG store)
store := ect.NewMemoryLedger()
resolver := func(kid string) (*ecdsa.PublicKey, error) { ... }
parsed, err := ect.Verify(compact, ect.VerifyOptions{
VerifierID: "spiffe://example.com/agent/b",
ResolveKey: resolver,
Store: store,
})
store.Append(compact, parsed.Payload)
```
### Demo
From the repo root (or `refimpl/`):
```bash
cd refimpl && go run ./cmd/demo
```
Runs a two-agent flow: Agent A issues a root ECT, Agent B verifies and appends it, then issues a child ECT; verification uses DAG validation against the ledger.
## Tests
```bash
cd refimpl && go test ./...
```
## Dependencies
- [go-jose/v4](https://github.com/go-jose/go-jose/v4) for JWS (ES256) and JWK handling. No custom crypto.
## Specification
- **Draft**: `draft-nennemann-wimse-execution-context-00`
- **Sections**: 4 (format), 5 (HTTP header), 6 (DAG), 7 (verification), 9 (ledger interface).
## License
Same as the Internet-Draft (IETF Trust). Code components under Revised BSD per BCP 78/79.

123
refimpl/cmd/demo/main.go Normal file
View File

@@ -0,0 +1,123 @@
// Demo runs a minimal two-agent ECT workflow: Agent A creates a root ECT,
// "sends" it to Agent B; Agent B verifies, appends to ledger, then creates
// a child ECT; verification runs with DAG validation against the ledger.
package main
import (
"crypto/ecdsa"
"fmt"
"log"
"time"
"github.com/nennemann/ect-refimpl/ect"
)
func main() {
ledger := ect.NewMemoryLedger()
now := time.Now()
// Agent A: spec reviewer
keyA, err := ect.GenerateKey()
if err != nil {
log.Fatal(err)
}
agentA := "spiffe://example.com/agent/spec-reviewer"
agentB := "spiffe://example.com/agent/implementer"
kidA := "agent-a-key"
// 1) Agent A creates root ECT
payloadA := &ect.Payload{
Iss: agentA,
Aud: []string{agentB},
Iat: now.Unix(),
Exp: now.Add(10 * time.Minute).Unix(),
Jti: "jti-a-001",
Wid: "wf-demo-001",
Tid: "task-001",
ExecAct: "review_requirements_spec",
Par: []string{},
Pol: "spec_review_policy_v2",
PolDecision: ect.PolDecisionApproved,
}
ectA, err := ect.Create(payloadA, keyA, ect.CreateOptions{KeyID: kidA})
if err != nil {
log.Fatal(err)
}
fmt.Println("Agent A created root ECT (task-001, review_requirements_spec)")
// 2) Agent B verifies (no store for DAG on first ECT)
resolveKey := func(kid string) (*ecdsa.PublicKey, error) {
if kid == kidA {
return &keyA.PublicKey, nil
}
return nil, nil
}
opts := ect.VerifyOptions{
VerifierID: agentB,
ResolveKey: resolveKey,
Store: ledger,
Now: now,
IATMaxAge: 15 * time.Minute,
IATMaxFuture: 30 * time.Second,
}
parsed, err := ect.Verify(ectA, opts)
if err != nil {
log.Fatal(err)
}
_, err = ledger.Append(ectA, parsed.Payload)
if err != nil {
log.Fatal(err)
}
fmt.Println("Agent B verified root ECT and appended to ledger")
// 3) Agent B creates child ECT (depends on task-001)
keyB, _ := ect.GenerateKey()
kidB := "agent-b-key"
payloadB := &ect.Payload{
Iss: agentB,
Aud: []string{"spiffe://example.com/system/ledger"},
Iat: now.Unix() + 1,
Exp: now.Add(10 * time.Minute).Unix(),
Jti: "jti-b-002",
Wid: "wf-demo-001",
Tid: "task-002",
ExecAct: "implement_module",
Par: []string{"task-001"},
Pol: "coding_standards_v3",
PolDecision: ect.PolDecisionApproved,
}
ectB, err := ect.Create(payloadB, keyB, ect.CreateOptions{KeyID: kidB})
if err != nil {
log.Fatal(err)
}
fmt.Println("Agent B created child ECT (task-002, implement_module, par=[task-001])")
// 4) Verify child ECT with DAG (ledger has task-001)
resolverB := ect.KeyResolver(func(kid string) (*ecdsa.PublicKey, error) {
if kid == kidB {
return &keyB.PublicKey, nil
}
if kid == kidA {
return &keyA.PublicKey, nil
}
return nil, nil
})
optsB := ect.VerifyOptions{
VerifierID: "spiffe://example.com/system/ledger",
ResolveKey: resolverB,
Store: ledger,
Now: now.Add(2 * time.Second),
IATMaxAge: 15 * time.Minute,
IATMaxFuture: 30 * time.Second,
}
parsedB, err := ect.Verify(ectB, optsB)
if err != nil {
log.Fatal(err)
}
_, err = ledger.Append(ectB, parsedB.Payload)
if err != nil {
log.Fatal(err)
}
fmt.Println("Verified child ECT with DAG validation and appended to ledger")
fmt.Printf("Ledger entries: task-001 (%s), task-002 (%s)\n", parsed.Payload.ExecAct, parsedB.Payload.ExecAct)
}

34
refimpl/ect/audience.go Normal file
View 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
View 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 515 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)
}

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

7
refimpl/go.mod Normal file
View File

@@ -0,0 +1,7 @@
module github.com/nennemann/ect-refimpl
go 1.22
require github.com/go-jose/go-jose/v4 v4.0.2
require golang.org/x/crypto v0.28.0 // indirect

4
refimpl/go.sum Normal file
View File

@@ -0,0 +1,4 @@
github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=

View File

@@ -0,0 +1,14 @@
{
"iss": "spiffe://example.com/agent/clinical",
"sub": "spiffe://example.com/agent/clinical",
"aud": "spiffe://example.com/agent/safety",
"iat": 1772064150,
"exp": 1772064750,
"jti": "7f3a8b2c-d1e4-4f56-9a0b-c3d4e5f6a7b8",
"wid": "a0b1c2d3-e4f5-6789-abcd-ef0123456789",
"tid": "550e8400-e29b-41d4-a716-446655440001",
"exec_act": "recommend_treatment",
"par": [],
"pol": "clinical_reasoning_policy_v2",
"pol_decision": "approved"
}