diff --git a/README.md b/README.md index 238a5b4..90edc84 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/refimpl/README.md b/refimpl/README.md new file mode 100644 index 0000000..e83937e --- /dev/null +++ b/refimpl/README.md @@ -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. diff --git a/refimpl/cmd/demo/main.go b/refimpl/cmd/demo/main.go new file mode 100644 index 0000000..f4a5566 --- /dev/null +++ b/refimpl/cmd/demo/main.go @@ -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) +} diff --git a/refimpl/ect/audience.go b/refimpl/ect/audience.go new file mode 100644 index 0000000..4946e00 --- /dev/null +++ b/refimpl/ect/audience.go @@ -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 +} diff --git a/refimpl/ect/create.go b/refimpl/ect/create.go new file mode 100644 index 0000000..f738da5 --- /dev/null +++ b/refimpl/ect/create.go @@ -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) +} diff --git a/refimpl/ect/create_test.go b/refimpl/ect/create_test.go new file mode 100644 index 0000000..f86a92a --- /dev/null +++ b/refimpl/ect/create_test.go @@ -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) + } +} diff --git a/refimpl/ect/dag.go b/refimpl/ect/dag.go new file mode 100644 index 0000000..c17a4ac --- /dev/null +++ b/refimpl/ect/dag.go @@ -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 +} diff --git a/refimpl/ect/dag_test.go b/refimpl/ect/dag_test.go new file mode 100644 index 0000000..543ebcb --- /dev/null +++ b/refimpl/ect/dag_test.go @@ -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") + } +} diff --git a/refimpl/ect/ledger.go b/refimpl/ect/ledger.go new file mode 100644 index 0000000..cca73ec --- /dev/null +++ b/refimpl/ect/ledger.go @@ -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") diff --git a/refimpl/ect/types.go b/refimpl/ect/types.go new file mode 100644 index 0000000..c4ca3d7 --- /dev/null +++ b/refimpl/ect/types.go @@ -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) +} diff --git a/refimpl/ect/verify.go b/refimpl/ect/verify.go new file mode 100644 index 0000000..20500b5 --- /dev/null +++ b/refimpl/ect/verify.go @@ -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 +} diff --git a/refimpl/go.mod b/refimpl/go.mod new file mode 100644 index 0000000..6a42d41 --- /dev/null +++ b/refimpl/go.mod @@ -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 diff --git a/refimpl/go.sum b/refimpl/go.sum new file mode 100644 index 0000000..2e1cb8d --- /dev/null +++ b/refimpl/go.sum @@ -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= diff --git a/refimpl/testdata/valid_root_ect_payload.json b/refimpl/testdata/valid_root_ect_payload.json new file mode 100644 index 0000000..667212d --- /dev/null +++ b/refimpl/testdata/valid_root_ect_payload.json @@ -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" +}