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:
97
refimpl/README.md
Normal file
97
refimpl/README.md
Normal 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
123
refimpl/cmd/demo/main.go
Normal 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
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
|
||||
}
|
||||
7
refimpl/go.mod
Normal file
7
refimpl/go.mod
Normal 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
4
refimpl/go.sum
Normal 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=
|
||||
14
refimpl/testdata/valid_root_ect_payload.json
vendored
Normal file
14
refimpl/testdata/valid_root_ect_payload.json
vendored
Normal 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"
|
||||
}
|
||||
Reference in New Issue
Block a user