Restructure refimpl into go-lang and python subdirectories

Move Go reference implementation to refimpl/go-lang/ and add new
Python reference implementation in refimpl/python/. Update build.sh
with renamed draft and simplified tool paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 23:11:55 +01:00
parent ff795c72e6
commit bbf557e54b
52 changed files with 3972 additions and 341 deletions

107
refimpl/go-lang/README.md Normal file
View File

@@ -0,0 +1,107 @@
# WIMSE ECT — Go Reference Implementation
Go reference implementation of [Execution Context Tokens (ECTs)](../../draft-nennemann-wimse-execution-context-00.txt) for WIMSE. Implements ECT creation (ES256), verification (Section 7), DAG validation (Section 6), and an in-memory audit ledger (Section 9).
## Layout
```
go-lang/
├── go.mod, go.sum
├── 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
│ ├── config.go # Config, LoadConfigFromEnv()
│ ├── jti_cache.go # JTICache for replay protection
│ ├── errors.go # Sentinel errors (ErrExpired, ErrReplay, etc.)
│ ├── validate.go # ValidateExt, ValidUUID, ValidateHashFormat
│ └── *_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/go-lang/ect"
// Production: load config from environment
cfg := ect.LoadConfigFromEnv()
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: "550e8400-e29b-41d4-a716-446655440000",
ExecAct: "review_spec",
Par: []string{},
Pol: "policy_v1",
PolDecision: ect.PolDecisionApproved,
}
compact, err := ect.Create(payload, key, cfg.CreateOptions("agent-a-key"))
// Verify with optional replay cache
store := ect.NewMemoryLedger()
var jtiCache ect.JTICache
if cfg.JTIReplaySize > 0 {
jtiCache = ect.NewJTICache(cfg.JTIReplaySize, cfg.JTIReplayTTL)
}
opts := cfg.VerifyOptions()
opts.VerifierID = "spiffe://example.com/agent/b"
opts.ResolveKey = resolver
opts.Store = store
if jtiCache != nil {
opts.JTISeen = jtiCache.Seen
}
parsed, err := ect.Verify(compact, opts)
if err != nil { ... }
if jtiCache != nil {
jtiCache.Add(parsed.Payload.Jti)
}
store.Append(compact, parsed.Payload)
```
### Demo
```bash
cd refimpl/go-lang && go run ./cmd/demo
```
### Tests
```bash
cd refimpl/go-lang && go test ./ect/... -cover
```
Unit tests are in `ect/*_test.go`. Coverage target: **~90%** (run `go test ./ect/... -coverprofile=cover.out && go tool cover -func=cover.out`). Remaining uncovered lines are mostly Parse/Verify error paths that require custom JWS or multi-sig tokens.
## Production configuration (environment)
| Variable | Default | Description |
|----------|---------|-------------|
| `ECT_IAT_MAX_AGE_MINUTES` | 15 | Max age of `iat` (minutes). |
| `ECT_IAT_MAX_FUTURE_SEC` | 30 | Max `iat` in future / clock skew (seconds). |
| `ECT_DEFAULT_EXPIRY_MIN` | 10 | Default token expiry when `exp` is zero (minutes). |
| `ECT_JTI_REPLAY_CACHE_SIZE` | 0 | Max JTIs to remember for replay check; 0 = disabled. |
| `ECT_JTI_REPLAY_TTL_MIN` | 60 | TTL for JTI cache entries (minutes). |
### Replay cache (multi-instance)
`JTICache` is in-memory only. For multiple verifier instances (e.g. behind a load balancer), use a shared store (Redis, database) so every instance sees the same “seen” JTIs. Implement `JTISeen` as a function that checks (and optionally records) the JTI in that store (e.g. with TTL). Pass it in `VerifyOptions.JTISeen`. See refimpl/README for an overview.
## Dependencies
- [go-jose/v4](https://github.com/go-jose/go-jose/v4) for JWS (ES256) and JWK handling.
## License
Same as the Internet-Draft (IETF Trust). Code under Revised BSD per BCP 78/79.

View File

@@ -0,0 +1,121 @@
// 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/go-lang/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 (task id = jti per spec)
payloadA := &ect.Payload{
Iss: agentA,
Aud: []string{agentB},
Iat: now.Unix(),
Exp: now.Add(10 * time.Minute).Unix(),
Jti: "550e8400-e29b-41d4-a716-446655440001",
Wid: "wf-demo-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 (jti=550e8400-..., 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 (par contains parent jti values per spec)
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: "550e8400-e29b-41d4-a716-446655440002",
Wid: "wf-demo-001",
ExecAct: "implement_module",
Par: []string{"550e8400-e29b-41d4-a716-446655440001"},
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 (jti=550e8400-...002, implement_module, par=[parent jti])")
// 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: %s (%s), %s (%s)\n", parsed.Payload.Jti, parsed.Payload.ExecAct, parsedB.Payload.Jti, parsedB.Payload.ExecAct)
}

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
}

View File

@@ -0,0 +1,84 @@
package ect
import (
"os"
"strconv"
"time"
)
// Env names for production configuration. All optional; zero values use defaults.
const (
EnvIATMaxAgeMinutes = "ECT_IAT_MAX_AGE_MINUTES" // max age of iat (default 15)
EnvIATMaxFutureSec = "ECT_IAT_MAX_FUTURE_SEC" // max iat in future / clock skew (default 30)
EnvDefaultExpiryMin = "ECT_DEFAULT_EXPIRY_MIN" // default token expiry when exp is zero (default 10)
EnvJTIReplayCacheSize = "ECT_JTI_REPLAY_CACHE_SIZE" // max JTIs to remember for replay check (0 = disabled)
EnvJTIReplayTTLMin = "ECT_JTI_REPLAY_TTL_MIN" // TTL for JTI cache entries (default 60)
)
// Config holds production-friendly settings loaded from environment.
type Config struct {
IATMaxAge time.Duration
IATMaxFuture time.Duration
DefaultExpiry time.Duration
JTIReplaySize int // 0 = no replay cache
JTIReplayTTL time.Duration
}
// DefaultConfig returns in-process defaults (no env).
func DefaultConfig() Config {
return Config{
IATMaxAge: 15 * time.Minute,
IATMaxFuture: 30 * time.Second,
DefaultExpiry: 10 * time.Minute,
JTIReplayTTL: 60 * time.Minute,
}
}
// LoadConfigFromEnv returns Config with values from environment where set.
func LoadConfigFromEnv() Config {
c := DefaultConfig()
if v := os.Getenv(EnvIATMaxAgeMinutes); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
c.IATMaxAge = time.Duration(n) * time.Minute
}
}
if v := os.Getenv(EnvIATMaxFutureSec); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
c.IATMaxFuture = time.Duration(n) * time.Second
}
}
if v := os.Getenv(EnvDefaultExpiryMin); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
c.DefaultExpiry = time.Duration(n) * time.Minute
}
}
if v := os.Getenv(EnvJTIReplayCacheSize); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
c.JTIReplaySize = n
}
}
if v := os.Getenv(EnvJTIReplayTTLMin); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
c.JTIReplayTTL = time.Duration(n) * time.Minute
}
}
return c
}
// CreateOptions returns CreateOptions from this config.
func (c Config) CreateOptions(keyID string) CreateOptions {
return CreateOptions{
KeyID: keyID,
IATMaxAge: c.IATMaxAge,
DefaultExpiry: c.DefaultExpiry,
}
}
// VerifyOptions returns VerifyOptions from this config (VerifierID, ResolveKey, Store must be set by caller).
func (c Config) VerifyOptions() VerifyOptions {
opts := DefaultVerifyOptions()
opts.IATMaxAge = c.IATMaxAge
opts.IATMaxFuture = c.IATMaxFuture
opts.DAG = DefaultDAGConfig()
return opts
}

View File

@@ -0,0 +1,79 @@
package ect
import (
"os"
"testing"
"time"
)
func TestDefaultConfig(t *testing.T) {
c := DefaultConfig()
if c.IATMaxAge != 15*time.Minute {
t.Errorf("IATMaxAge: got %v", c.IATMaxAge)
}
if c.JTIReplaySize != 0 {
t.Errorf("JTIReplaySize: got %d", c.JTIReplaySize)
}
}
func TestLoadConfigFromEnv(t *testing.T) {
os.Setenv(EnvIATMaxAgeMinutes, "20")
os.Setenv(EnvJTIReplayCacheSize, "1000")
os.Setenv(EnvIATMaxFutureSec, "45")
os.Setenv(EnvDefaultExpiryMin, "12")
os.Setenv(EnvJTIReplayTTLMin, "90")
defer func() {
os.Unsetenv(EnvIATMaxAgeMinutes)
os.Unsetenv(EnvJTIReplayCacheSize)
os.Unsetenv(EnvIATMaxFutureSec)
os.Unsetenv(EnvDefaultExpiryMin)
os.Unsetenv(EnvJTIReplayTTLMin)
}()
c := LoadConfigFromEnv()
if c.IATMaxAge != 20*time.Minute {
t.Errorf("IATMaxAge from env: got %v", c.IATMaxAge)
}
if c.JTIReplaySize != 1000 {
t.Errorf("JTIReplaySize from env: got %d", c.JTIReplaySize)
}
if c.IATMaxFuture != 45*time.Second {
t.Errorf("IATMaxFuture: got %v", c.IATMaxFuture)
}
if c.DefaultExpiry != 12*time.Minute {
t.Errorf("DefaultExpiry: got %v", c.DefaultExpiry)
}
if c.JTIReplayTTL != 90*time.Minute {
t.Errorf("JTIReplayTTL: got %v", c.JTIReplayTTL)
}
}
func TestLoadConfigFromEnv_invalidInt(t *testing.T) {
os.Setenv(EnvIATMaxAgeMinutes, "bad")
defer os.Unsetenv(EnvIATMaxAgeMinutes)
c := LoadConfigFromEnv()
if c.IATMaxAge != 15*time.Minute {
t.Errorf("invalid int should keep default: got %v", c.IATMaxAge)
}
}
func TestConfig_CreateOptions(t *testing.T) {
c := DefaultConfig()
opts := c.CreateOptions("my-kid")
if opts.KeyID != "my-kid" {
t.Errorf("KeyID: got %q", opts.KeyID)
}
if opts.DefaultExpiry != c.DefaultExpiry {
t.Errorf("DefaultExpiry: got %v", opts.DefaultExpiry)
}
}
func TestConfig_VerifyOptions(t *testing.T) {
c := DefaultConfig()
opts := c.VerifyOptions()
if opts.IATMaxAge != c.IATMaxAge {
t.Errorf("IATMaxAge: got %v", opts.IATMaxAge)
}
if opts.DAG.ClockSkewTolerance != DefaultClockSkewTolerance {
t.Errorf("DAG.ClockSkewTolerance: got %d", opts.DAG.ClockSkewTolerance)
}
}

View File

@@ -0,0 +1,152 @@
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
// IATMaxAge 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
// ValidateUUIDs when true requires jti and wid (if set) to be UUID format (RFC 9562).
ValidateUUIDs bool
// MaxParLength is the max number of parent references (0 = no limit; recommended 100).
MaxParLength int
}
// 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).
// Create may modify the payload in place (Iat, Exp, Sub, Par) when filling defaults; pass a copy if the original must stay unchanged.
func Create(payload *Payload, privateKey *ecdsa.PrivateKey, opts CreateOptions) (compact string, err error) {
if payload == nil || privateKey == nil {
return "", ErrPayloadRequired
}
if opts.KeyID == "" {
return "", ErrKeyIDRequired
}
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, opts); 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, opts CreateOptions) 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.ExecAct == "" {
return errors.New("ect: exec_act required")
}
if opts.ValidateUUIDs {
if !ValidUUID(p.Jti) {
return ErrInvalidJTI
}
if p.Wid != "" && !ValidUUID(p.Wid) {
return ErrInvalidWID
}
}
if opts.MaxParLength > 0 && len(p.Par) > opts.MaxParLength {
return ErrParLength
}
if p.InpHash != "" {
if err := ValidateHashFormat(p.InpHash); err != nil {
return err
}
}
if p.OutHash != "" {
if err := ValidateHashFormat(p.OutHash); err != nil {
return err
}
}
if err := ValidateExt(p.Ext); err != nil {
return err
}
// pol/pol_decision are OPTIONAL; if either is set, both must be present and valid
if p.Pol != "" || p.PolDecision != "" {
if p.Pol == "" || p.PolDecision == "" {
return ErrPolPolDecisionPair
}
if !ValidPolDecision(p.PolDecision) {
return ErrInvalidPolDecision
}
}
// compensation_* live in ext per spec
if p.Ext != nil {
if _, hasReason := p.Ext["compensation_reason"]; hasReason {
if v, _ := p.Ext["compensation_required"].(bool); !v {
return errors.New("ect: ext.compensation_reason requires ext.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,192 @@
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",
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.Jti != payload.Jti || parsed.Payload.ExecAct != payload.ExecAct {
t.Errorf("payload mismatch: got jti=%q exec_act=%q", parsed.Payload.Jti, parsed.Payload.ExecAct)
}
}
func TestDefaultCreateOptions(t *testing.T) {
opts := DefaultCreateOptions()
if opts.KeyID != "" {
t.Errorf("KeyID: got %q", opts.KeyID)
}
if opts.DefaultExpiry != 10*time.Minute {
t.Errorf("DefaultExpiry: got %v", opts.DefaultExpiry)
}
}
func TestCreate_Errors(t *testing.T) {
key, _ := GenerateKey()
payload := &Payload{Iss: "i", Aud: []string{"a"}, Jti: "j", ExecAct: "e", Par: []string{}, Pol: "p", PolDecision: PolDecisionApproved, Iat: 1, Exp: 2}
if _, err := Create(nil, key, CreateOptions{KeyID: "k"}); err == nil {
t.Error("expected error for nil payload")
}
if _, err := Create(payload, nil, CreateOptions{KeyID: "k"}); err == nil {
t.Error("expected error for nil key")
}
if _, err := Create(payload, key, CreateOptions{KeyID: ""}); err == nil {
t.Error("expected error for empty KeyID")
}
}
func TestCreate_OptionalPol(t *testing.T) {
key, _ := GenerateKey()
now := time.Now()
payload := &Payload{
Iss: "iss", Aud: []string{"aud"}, Iat: now.Unix(), Exp: now.Add(time.Hour).Unix(),
Jti: "jti-nopol", ExecAct: "act", Par: []string{},
}
compact, err := Create(payload, key, CreateOptions{KeyID: "kid"})
if err != nil {
t.Fatal(err)
}
if compact == "" {
t.Fatal("expected token without pol")
}
}
func TestCreate_ZeroExpiryUsesDefault(t *testing.T) {
key, _ := GenerateKey()
payload := &Payload{
Iss: "i", Aud: []string{"a"}, Iat: 0, Exp: 0,
Jti: "jti-z", ExecAct: "e", Par: []string{},
}
_, err := Create(payload, key, CreateOptions{KeyID: "kid", DefaultExpiry: 5 * time.Minute})
if err != nil {
t.Fatal(err)
}
if payload.Exp <= payload.Iat {
t.Error("exp should be after iat")
}
}
func TestCreate_ExtCompensationReasonRequiresRequired(t *testing.T) {
key, _ := GenerateKey()
payload := &Payload{
Iss: "i", Aud: []string{"a"}, Iat: 1, Exp: 2,
Jti: "j", ExecAct: "e", Par: []string{},
Ext: map[string]interface{}{"compensation_reason": "rollback", "compensation_required": false},
}
_, err := Create(payload, key, CreateOptions{KeyID: "k"})
if err == nil {
t.Fatal("expected error when ext has compensation_reason without compensation_required true")
}
}
func TestCreate_ValidationErrors(t *testing.T) {
key, _ := GenerateKey()
tests := []struct {
name string
p *Payload
}{
{"missing iss", &Payload{Iss: "", Aud: []string{"a"}, Jti: "j", ExecAct: "e", Par: []string{}, Iat: 1, Exp: 2}},
{"missing aud", &Payload{Iss: "i", Aud: nil, Jti: "j", ExecAct: "e", Par: []string{}, Iat: 1, Exp: 2}},
{"missing jti", &Payload{Iss: "i", Aud: []string{"a"}, Jti: "", ExecAct: "e", Par: []string{}, Iat: 1, Exp: 2}},
{"missing exec_act", &Payload{Iss: "i", Aud: []string{"a"}, Jti: "j", ExecAct: "", Par: []string{}, Iat: 1, Exp: 2}},
{"pol without pol_decision", &Payload{Iss: "i", Aud: []string{"a"}, Jti: "j", ExecAct: "e", Par: []string{}, Pol: "p", PolDecision: "", Iat: 1, Exp: 2}},
{"invalid pol_decision", &Payload{Iss: "i", Aud: []string{"a"}, Jti: "j", ExecAct: "e", Par: []string{}, Pol: "p", PolDecision: "bad", Iat: 1, Exp: 2}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if _, err := Create(tt.p, key, CreateOptions{KeyID: "k"}); err == nil {
t.Error("expected validation error")
}
})
}
}
func TestCreateWithTestVector(t *testing.T) {
// testdata at module root (go-lang/testdata/); test cwd is package dir (ect/)
data, err := os.ReadFile("../testdata/valid_root_ect_payload.json")
if err != nil {
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)
}
}

114
refimpl/go-lang/ect/dag.go Normal file
View File

@@ -0,0 +1,114 @@
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
MaxParLength int // max par length (0 = no limit; recommended 100)
}
// DefaultDAGConfig returns recommended defaults.
func DefaultDAGConfig() DAGConfig {
return DAGConfig{
ClockSkewTolerance: DefaultClockSkewTolerance,
MaxAncestorLimit: DefaultMaxAncestorLimit,
MaxParLength: DefaultMaxParLength,
}
}
// 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
}
if cfg.MaxParLength > 0 && len(ect.Par) > cfg.MaxParLength {
return ErrParLength
}
// 1. Task ID Uniqueness (task id = jti per spec)
if store.Contains(ect.Jti, ect.Wid) {
return fmt.Errorf("ect: task ID (jti) already exists: %s", ect.Jti)
}
// 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.Jti, ect.Par, store, visited, cfg.MaxAncestorLimit) {
return errors.New("ect: circular dependency or depth limit exceeded")
}
// 5. Parent Policy Decision (only when parent has policy claims per spec)
for _, parentID := range ect.Par {
parent := store.GetByTid(parentID)
if parent != nil && parent.HasPolicyClaims() &&
(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 ext.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
}

View File

@@ -0,0 +1,140 @@
package ect
import (
"testing"
"time"
)
func TestValidateDAG_Root(t *testing.T) {
store := NewMemoryLedger()
payload := &Payload{
Jti: "jti-001",
Wid: "wf-1",
Par: []string{},
PolDecision: PolDecisionApproved,
}
err := ValidateDAG(payload, store, DefaultDAGConfig())
if err != nil {
t.Fatal(err)
}
}
func TestValidateDAG_DuplicateJti(t *testing.T) {
store := NewMemoryLedger()
_, _ = store.Append("dummy-jws", &Payload{Jti: "jti-001", Wid: "wf-1", Par: []string{}, PolDecision: PolDecisionApproved})
payload := &Payload{Jti: "jti-001", Wid: "wf-1", Par: []string{}, PolDecision: PolDecisionApproved}
err := ValidateDAG(payload, store, DefaultDAGConfig())
if err == nil {
t.Fatal("expected error for duplicate jti")
}
}
func TestValidateDAG_ParentExists(t *testing.T) {
store := NewMemoryLedger()
_, _ = store.Append("jws1", &Payload{Jti: "jti-001", Wid: "wf-1", Par: []string{}, PolDecision: PolDecisionApproved, Iat: time.Now().Unix() - 60})
payload := &Payload{
Jti: "jti-002",
Wid: "wf-1",
Par: []string{"jti-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{
Jti: "jti-002",
Par: []string{"jti-missing"},
PolDecision: PolDecisionApproved,
Iat: time.Now().Unix(),
}
err := ValidateDAG(payload, store, DefaultDAGConfig())
if err == nil {
t.Fatal("expected error when parent not found")
}
}
func TestValidateDAG_DepthLimit(t *testing.T) {
store := NewMemoryLedger()
now := time.Now().Unix()
// Chain: jti-1 -> jti-2 -> jti-3 -> ...; validate with maxAncestorLimit=2 so we exceed it
_, _ = store.Append("jws1", &Payload{Jti: "jti-1", Wid: "wf", Par: []string{}, PolDecision: PolDecisionApproved, Iat: now - 100})
_, _ = store.Append("jws2", &Payload{Jti: "jti-2", Wid: "wf", Par: []string{"jti-1"}, PolDecision: PolDecisionApproved, Iat: now - 50})
cfg := DAGConfig{ClockSkewTolerance: DefaultClockSkewTolerance, MaxAncestorLimit: 2}
payload := &Payload{Jti: "jti-3", Wid: "wf", Par: []string{"jti-2"}, PolDecision: PolDecisionApproved, Iat: now}
err := ValidateDAG(payload, store, cfg)
if err == nil {
t.Fatal("expected error when ancestor limit exceeded")
}
}
func TestValidateDAG_StoreNil(t *testing.T) {
payload := &Payload{Jti: "j1", Par: []string{}, PolDecision: PolDecisionApproved, Iat: time.Now().Unix()}
err := ValidateDAG(payload, nil, DefaultDAGConfig())
if err == nil {
t.Fatal("expected error when store is nil")
}
}
func TestValidateDAG_TemporalOrdering(t *testing.T) {
store := NewMemoryLedger()
now := time.Now().Unix()
_, _ = store.Append("jws1", &Payload{Jti: "jti-1", Wid: "wf", Par: []string{}, PolDecision: PolDecisionApproved, Iat: now})
// child has iat before parent + skew: parent.iat (now) >= child.iat (now+100) + 30 => invalid
payload := &Payload{Jti: "jti-2", Wid: "wf", Par: []string{"jti-1"}, PolDecision: PolDecisionApproved, Iat: now + 100}
err := ValidateDAG(payload, store, DAGConfig{ClockSkewTolerance: 30, MaxAncestorLimit: 10000})
if err != nil {
t.Fatal(err)
}
// parent.iat >= child.iat + skew: parent at now+50, child at now+10, skew 30 => 50 >= 40 => invalid
_, _ = store.Append("jws2", &Payload{Jti: "jti-1b", Wid: "wf", Par: []string{}, PolDecision: PolDecisionApproved, Iat: now + 50})
payload2 := &Payload{Jti: "jti-2b", Wid: "wf", Par: []string{"jti-1b"}, PolDecision: PolDecisionApproved, Iat: now + 10}
err = ValidateDAG(payload2, store, DAGConfig{ClockSkewTolerance: 30, MaxAncestorLimit: 10000})
if err == nil {
t.Fatal("expected error when parent not earlier than child")
}
}
func TestValidateDAG_DirectCycle(t *testing.T) {
// par contains own jti (direct self-reference) -> parent not found
store := NewMemoryLedger()
now := time.Now().Unix()
payload := &Payload{Jti: "jti-self", Wid: "wf", Par: []string{"jti-self"}, PolDecision: PolDecisionApproved, Iat: now}
err := ValidateDAG(payload, store, DefaultDAGConfig())
if err == nil {
t.Fatal("expected error for direct cycle (par contains self)")
}
}
func TestValidateDAG_hasCycle_visitedContinue(t *testing.T) {
// par has duplicate parent ID so we hit "if _, ok := visited[parentID]; ok { continue }"
store := NewMemoryLedger()
now := time.Now().Unix()
_, _ = store.Append("jws1", &Payload{Jti: "jti-a", Wid: "wf", Par: []string{}, PolDecision: PolDecisionApproved, Iat: now - 10})
payload := &Payload{Jti: "jti-b", Wid: "wf", Par: []string{"jti-a", "jti-a"}, PolDecision: PolDecisionApproved, Iat: now}
err := ValidateDAG(payload, store, DefaultDAGConfig())
if err != nil {
t.Fatal(err)
}
}
func TestValidateDAG_ParentPolicyRejected_RequiresCompensation(t *testing.T) {
store := NewMemoryLedger()
now := time.Now().Unix()
_, _ = store.Append("jws1", &Payload{Jti: "jti-rej", Wid: "wf", Par: []string{}, Pol: "p", PolDecision: PolDecisionRejected, Iat: now - 60})
payload := &Payload{Jti: "jti-child", Wid: "wf", Par: []string{"jti-rej"}, PolDecision: PolDecisionApproved, Iat: now}
err := ValidateDAG(payload, store, DefaultDAGConfig())
if err == nil {
t.Fatal("expected error when parent rejected and no compensation")
}
payload.Ext = map[string]interface{}{"compensation_required": true}
err = ValidateDAG(payload, store, DefaultDAGConfig())
if err != nil {
t.Fatal(err)
}
}

View File

@@ -0,0 +1,29 @@
package ect
import "errors"
// Sentinel errors for programmatic handling and logging.
var (
ErrPayloadRequired = errors.New("ect: payload and privateKey required")
ErrKeyIDRequired = errors.New("ect: KeyID required")
ErrInvalidTyp = errors.New("ect: invalid typ parameter")
ErrProhibitedAlg = errors.New("ect: prohibited algorithm")
ErrMissingKid = errors.New("ect: missing kid")
ErrUnknownKey = errors.New("ect: unknown key identifier")
ErrWITSubjectMismatch = errors.New("ect: issuer does not match WIT subject")
ErrAudienceMismatch = errors.New("ect: audience does not include verifier")
ErrExpired = errors.New("ect: token expired")
ErrIATTooOld = errors.New("ect: iat too far in the past")
ErrIATInFuture = errors.New("ect: iat in the future")
ErrMissingClaims = errors.New("ect: missing required claims (jti, exec_act, par)")
ErrPolPolDecisionPair = errors.New("ect: pol and pol_decision must both be present when either is set")
ErrInvalidPolDecision = errors.New("ect: invalid pol_decision value")
ErrReplay = errors.New("ect: jti already seen (replay)")
ErrResolveKeyRequired = errors.New("ect: ResolveKey required")
ErrExtSize = errors.New("ect: ext exceeds max size (4096 bytes)")
ErrExtDepth = errors.New("ect: ext exceeds max nesting depth (5)")
ErrInvalidJTI = errors.New("ect: jti must be UUID format")
ErrInvalidWID = errors.New("ect: wid must be UUID format when set")
ErrParLength = errors.New("ect: par exceeds max length")
ErrHashFormat = errors.New("ect: inp_hash/out_hash must be algorithm:base64url (e.g. sha-256:...)")
)

View File

@@ -0,0 +1,69 @@
package ect
import (
"sync"
"time"
)
// JTICache provides replay protection by remembering seen JTIs. Safe for concurrent use.
// Use as VerifyOptions.JTISeen: cache.Seen(jti) and call cache.Add(jti) after successful verify.
type JTICache interface {
// Seen returns true if jti was already added (replay).
Seen(jti string) bool
// Add records jti. Call after successful verification before accepting the token.
Add(jti string)
}
type jtiEntry struct {
expiresAt time.Time
}
// NewJTICache returns an in-memory JTI cache with optional max size and TTL.
// maxSize 0 means unbounded; entries are evicted after ttl.
func NewJTICache(maxSize int, ttl time.Duration) JTICache {
return &jtiCache{
byJti: make(map[string]jtiEntry),
maxSize: maxSize,
ttl: ttl,
}
}
type jtiCache struct {
mu sync.RWMutex
byJti map[string]jtiEntry
maxSize int
ttl time.Duration
}
func (c *jtiCache) Seen(jti string) bool {
c.mu.RLock()
defer c.mu.RUnlock()
e, ok := c.byJti[jti]
if !ok {
return false
}
if time.Now().After(e.expiresAt) {
return false
}
return true
}
func (c *jtiCache) Add(jti string) {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
// Evict expired
for k, v := range c.byJti {
if now.After(v.expiresAt) {
delete(c.byJti, k)
}
}
if c.maxSize > 0 && len(c.byJti) >= c.maxSize && c.byJti[jti].expiresAt.IsZero() {
// Evict one oldest (simple: remove first we see)
for k := range c.byJti {
delete(c.byJti, k)
break
}
}
c.byJti[jti] = jtiEntry{expiresAt: now.Add(c.ttl)}
}

View File

@@ -0,0 +1,56 @@
package ect
import (
"testing"
"time"
)
func TestJTICache_SeenAndAdd(t *testing.T) {
cache := NewJTICache(10, 1*time.Second)
if cache.Seen("jti-1") {
t.Error("unseen jti should not be seen")
}
cache.Add("jti-1")
if !cache.Seen("jti-1") {
t.Error("added jti should be seen")
}
if cache.Seen("jti-2") {
t.Error("other jti should not be seen")
}
cache.Add("jti-2")
if !cache.Seen("jti-2") {
t.Error("second jti should be seen")
}
}
func TestJTICache_Expiry(t *testing.T) {
cache := NewJTICache(10, 50*time.Millisecond)
cache.Add("jti-1")
if !cache.Seen("jti-1") {
t.Fatal("added jti should be seen")
}
time.Sleep(60 * time.Millisecond)
if cache.Seen("jti-1") {
t.Error("expired jti should not be seen")
}
}
func TestJTICache_MaxSizeEviction(t *testing.T) {
cache := NewJTICache(2, 5*time.Second)
cache.Add("jti-1")
cache.Add("jti-2")
cache.Add("jti-3") // evicts one
// At least one of jti-1, jti-2 may be evicted; jti-3 must be present
if !cache.Seen("jti-3") {
t.Error("newest jti should be present")
}
}
func TestJTICache_AddWhenAlreadyPresent(t *testing.T) {
cache := NewJTICache(2, 5*time.Second)
cache.Add("jti-1")
cache.Add("jti-1") // refresh same jti; should not evict
if !cache.Seen("jti-1") {
t.Error("jti-1 should still be present")
}
}

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: jti (task id) must not already exist (scoped by wid when present) per spec
wid := payload.Wid
if m.containsLocked(payload.Jti, wid) {
return 0, ErrTaskIDExists
}
m.seq++
entry := LedgerEntry{
LedgerSequence: m.seq,
TaskID: payload.Jti, // task id = jti per spec
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.Jti] = 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 jti already exists.
var ErrTaskIDExists = errors.New("ect: task ID (jti) already exists in ledger")

View File

@@ -0,0 +1,58 @@
package ect
import (
"testing"
"time"
)
func TestMemoryLedger_AppendAndGet(t *testing.T) {
m := NewMemoryLedger()
p := &Payload{Jti: "jti-1", Iss: "iss", ExecAct: "act", Par: []string{}, Iat: time.Now().Unix(), Exp: time.Now().Add(time.Hour).Unix()}
seq, err := m.Append("jws1", p)
if err != nil {
t.Fatal(err)
}
if seq != 1 {
t.Errorf("seq: got %d", seq)
}
got := m.GetByTid("jti-1")
if got == nil || got.Jti != "jti-1" {
t.Errorf("GetByTid: got %v", got)
}
}
func TestMemoryLedger_ErrTaskIDExists(t *testing.T) {
m := NewMemoryLedger()
p := &Payload{Jti: "jti-dup", Iss: "i", ExecAct: "e", Par: []string{}, Iat: 1, Exp: 2}
_, _ = m.Append("jws1", p)
_, err := m.Append("jws2", p)
if err != ErrTaskIDExists {
t.Errorf("expected ErrTaskIDExists, got %v", err)
}
}
func TestMemoryLedger_ContainsWid(t *testing.T) {
m := NewMemoryLedger()
p := &Payload{Jti: "j1", Wid: "wf1", Iss: "i", ExecAct: "e", Par: []string{}, Iat: 1, Exp: 2}
_, _ = m.Append("jws", p)
if !m.Contains("j1", "") {
t.Error("Contains(j1, \"\") should be true")
}
if !m.Contains("j1", "wf1") {
t.Error("Contains(j1, wf1) should be true")
}
if m.Contains("j1", "wf2") {
t.Error("Contains(j1, wf2) should be false")
}
}
func TestMemoryLedger_AppendNilPayload(t *testing.T) {
m := NewMemoryLedger()
seq, err := m.Append("jws", nil)
if err != nil {
t.Fatal(err)
}
if seq != 0 {
t.Errorf("Append(nil) should return 0, got %d", seq)
}
}

View File

@@ -0,0 +1,102 @@
// 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 / exec-claims)
// Task identity is jti only; no separate "tid" claim per spec.
Wid string `json:"wid,omitempty"` // OPTIONAL: workflow ID, UUID
ExecAct string `json:"exec_act"` // REQUIRED
Par []string `json:"par"` // REQUIRED: parent jti values
// Policy evaluation (Section 4.2.3 / policy-claims) — OPTIONAL
Pol string `json:"pol,omitempty"`
PolDecision string `json:"pol_decision,omitempty"`
PolEnforcer string `json:"pol_enforcer,omitempty"`
PolTimestamp int64 `json:"pol_timestamp,omitempty"`
// Data integrity (Section 4.2.4)
InpHash string `json:"inp_hash,omitempty"`
OutHash string `json:"out_hash,omitempty"`
InpClassification string `json:"inp_classification,omitempty"`
// Extensions (Section 4.2.7): exec_time_ms, regulated_domain, model_version,
// witnessed_by, inp_classification, pol_timestamp, compensation_required, compensation_reason
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)
}
// CompensationRequired returns true if ext["compensation_required"] is true (per spec extension).
func (p *Payload) CompensationRequired() bool {
if p.Ext == nil {
return false
}
v, _ := p.Ext["compensation_required"].(bool)
return v
}
// HasPolicyClaims returns true if both pol and pol_decision are present (optional pair per spec).
func (p *Payload) HasPolicyClaims() bool {
return p.Pol != "" && p.PolDecision != ""
}

View File

@@ -0,0 +1,115 @@
package ect
import (
"encoding/json"
"testing"
"time"
)
func TestAudience_MarshalJSON_single(t *testing.T) {
a := Audience{"single"}
b, err := json.Marshal(a)
if err != nil {
t.Fatal(err)
}
if string(b) != `"single"` {
t.Errorf("single aud: got %s", string(b))
}
}
func TestAudience_MarshalJSON_multi(t *testing.T) {
a := Audience{"a", "b"}
b, err := json.Marshal(a)
if err != nil {
t.Fatal(err)
}
if string(b) != `["a","b"]` {
t.Errorf("multi aud: got %s", string(b))
}
}
func TestAudience_UnmarshalJSON_array(t *testing.T) {
var a Audience
err := json.Unmarshal([]byte(`["x","y"]`), &a)
if err != nil {
t.Fatal(err)
}
if len(a) != 2 || a[0] != "x" || a[1] != "y" {
t.Errorf("unmarshal array: got %v", a)
}
}
func TestAudience_UnmarshalJSON_string(t *testing.T) {
var a Audience
err := json.Unmarshal([]byte(`"single"`), &a)
if err != nil {
t.Fatal(err)
}
if len(a) != 1 || a[0] != "single" {
t.Errorf("unmarshal string: got %v", a)
}
}
func TestAudience_UnmarshalJSON_invalid(t *testing.T) {
var a Audience
// aud must be string or array; object is invalid
err := json.Unmarshal([]byte(`{}`), &a)
if err == nil {
t.Error("expected error for invalid aud type")
}
}
func TestValidPolDecision(t *testing.T) {
if !ValidPolDecision(PolDecisionApproved) {
t.Error("approved should be valid")
}
if !ValidPolDecision(PolDecisionRejected) {
t.Error("rejected should be valid")
}
if ValidPolDecision("invalid") {
t.Error("invalid should not be valid")
}
}
func TestPayload_ContainsAudience(t *testing.T) {
p := &Payload{Aud: []string{"a", "b"}}
if !p.ContainsAudience("a") {
t.Error("should contain a")
}
if p.ContainsAudience("c") {
t.Error("should not contain c")
}
}
func TestPayload_IATTime_ExpTime(t *testing.T) {
ts := int64(1000000)
p := &Payload{Iat: ts, Exp: ts + 100}
if !p.IATTime().Equal(time.Unix(ts, 0)) {
t.Error("IATTime mismatch")
}
if !p.ExpTime().Equal(time.Unix(ts+100, 0)) {
t.Error("ExpTime mismatch")
}
}
func TestPayload_CompensationRequired(t *testing.T) {
p := &Payload{}
if p.CompensationRequired() {
t.Error("nil Ext should not be compensation")
}
p.Ext = map[string]interface{}{"compensation_required": true}
if !p.CompensationRequired() {
t.Error("ext compensation_required true should return true")
}
}
func TestPayload_HasPolicyClaims(t *testing.T) {
p := &Payload{Pol: "p", PolDecision: PolDecisionApproved}
if !p.HasPolicyClaims() {
t.Error("both pol and pol_decision set should have policy claims")
}
p.Pol = ""
if p.HasPolicyClaims() {
t.Error("missing pol should not have policy claims")
}
}

View File

@@ -0,0 +1,83 @@
package ect
import (
"encoding/base64"
"encoding/json"
"regexp"
"strings"
)
// ExtMaxSize is the recommended max serialized size of ext (Section 4.2.7).
const ExtMaxSize = 4096
// ExtMaxDepth is the recommended max JSON nesting depth in ext.
const ExtMaxDepth = 5
// DefaultMaxParLength is the recommended max number of parent references.
const DefaultMaxParLength = 100
// uuidRegex matches RFC 9562 UUID: 8-4-4-4-12 hex.
var uuidRegex = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
// allowedHashAlgs are the spec-recommended hash algorithm prefixes for inp_hash/out_hash.
var allowedHashAlgs = map[string]bool{"sha-256": true, "sha-384": true, "sha-512": true}
// ValidateExt returns an error if ext exceeds ExtMaxSize or ExtMaxDepth.
func ValidateExt(ext map[string]interface{}) error {
if ext == nil || len(ext) == 0 {
return nil
}
b, err := json.Marshal(ext)
if err != nil {
return err
}
if len(b) > ExtMaxSize {
return ErrExtSize
}
if depth(b) > ExtMaxDepth {
return ErrExtDepth
}
return nil
}
func depth(b []byte) int {
var max, level int
for _, c := range b {
switch c {
case '{', '[':
level++
if level > max {
max = level
}
case '}', ']':
level--
}
}
return max
}
// ValidUUID returns true if s is a UUID string (RFC 9562).
func ValidUUID(s string) bool {
return uuidRegex.MatchString(s)
}
// ValidateHashFormat returns nil if s is empty or matches "algorithm:base64url" (sha-256, sha-384, sha-512).
func ValidateHashFormat(s string) error {
if s == "" {
return nil
}
idx := strings.Index(s, ":")
if idx <= 0 {
return ErrHashFormat
}
alg := strings.ToLower(s[:idx])
if !allowedHashAlgs[alg] {
return ErrHashFormat
}
encoded := s[idx+1:]
if encoded == "" {
return ErrHashFormat
}
_, err := base64.RawURLEncoding.DecodeString(encoded)
return err
}

View File

@@ -0,0 +1,237 @@
package ect
import (
"crypto/ecdsa"
"crypto/subtle"
"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.
Store ECTStore
// DAG 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
// ValidateUUIDs when true requires jti and wid (if set) to be UUID format.
ValidateUUIDs bool
// MaxParLength caps par length (0 = no limit). Applied before DAG; DAG may also enforce via DAG.MaxParLength.
MaxParLength int
// LogVerify if set is called after verification with jti and any error (for observability).
LogVerify func(jti string, err error)
}
// 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) (parsed *ParsedECT, err error) {
var logJti string
defer func() {
if opts.LogVerify != nil {
opts.LogVerify(logJti, err)
}
}()
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
// 2. typ must be wimse-exec+jwt (constant-time compare)
typ, _ := header.ExtraHeaders["typ"].(string)
if typ == "" {
typ, _ = header.ExtraHeaders[jose.HeaderType].(string)
}
if subtle.ConstantTimeCompare([]byte(typ), []byte(ECTType)) != 1 {
return nil, ErrInvalidTyp
}
// 3. alg not none and not symmetric
if header.Algorithm == "none" || header.Algorithm == "HS256" || header.Algorithm == "HS384" || header.Algorithm == "HS512" {
return nil, ErrProhibitedAlg
}
// 4. kid references known key
kid := header.KeyID
if kid == "" {
return nil, ErrMissingKid
}
pub, err := opts.ResolveKey(kid)
if err != nil {
return nil, fmt.Errorf("ect: key resolution: %w", err)
}
if pub == nil {
return nil, ErrUnknownKey
}
// 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
}
logJti = p.Jti
if err := ValidateExt(p.Ext); 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, ErrWITSubjectMismatch
}
// 9. aud contains verifier
if opts.VerifierID != "" && !p.ContainsAudience(opts.VerifierID) {
return nil, ErrAudienceMismatch
}
// 10. exp not expired
now := opts.Now
if now.IsZero() {
now = time.Now()
}
if now.Unix() > p.Exp {
return nil, ErrExpired
}
// 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, ErrIATTooOld
}
if p.Iat > now.Unix()+int64(opts.IATMaxFuture.Seconds()) {
return nil, ErrIATInFuture
}
// 12. Required claims present (jti, exec_act, par)
if p.Jti == "" || p.ExecAct == "" {
return nil, ErrMissingClaims
}
if p.Par == nil {
p.Par = []string{}
}
if opts.MaxParLength > 0 && len(p.Par) > opts.MaxParLength {
return nil, ErrParLength
}
if opts.ValidateUUIDs {
if !ValidUUID(p.Jti) {
return nil, ErrInvalidJTI
}
if p.Wid != "" && !ValidUUID(p.Wid) {
return nil, ErrInvalidWID
}
}
if p.InpHash != "" {
if err := ValidateHashFormat(p.InpHash); err != nil {
return nil, err
}
}
if p.OutHash != "" {
if err := ValidateHashFormat(p.OutHash); err != nil {
return nil, err
}
}
// 13. If pol or pol_decision present, both must be present and pol_decision in registry
if p.Pol != "" || p.PolDecision != "" {
if p.Pol == "" || p.PolDecision == "" {
return nil, ErrPolPolDecisionPair
}
if !ValidPolDecision(p.PolDecision) {
return nil, ErrInvalidPolDecision
}
}
// 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, ErrReplay
}
return &ParsedECT{Header: header, Payload: &p, Raw: compact}, nil
}

View File

@@ -0,0 +1,243 @@
package ect
import (
"crypto/ecdsa"
"errors"
"testing"
"time"
)
func TestParse(t *testing.T) {
key, _ := GenerateKey()
now := time.Now()
payload := &Payload{
Iss: "iss",
Aud: []string{"aud"},
Iat: now.Unix(),
Exp: now.Add(time.Hour).Unix(),
Jti: "jti-parse",
ExecAct: "act",
Par: []string{},
Pol: "pol",
PolDecision: PolDecisionApproved,
}
compact, err := Create(payload, key, CreateOptions{KeyID: "kid"})
if err != nil {
t.Fatal(err)
}
parsed, err := Parse(compact)
if err != nil {
t.Fatal(err)
}
if parsed.Payload.Jti != "jti-parse" {
t.Errorf("jti: got %q", parsed.Payload.Jti)
}
if parsed.Raw != compact {
t.Error("Raw should match compact")
}
}
func TestVerify_InvalidTyp(t *testing.T) {
// Create token then tamper header to test typ check would need different alg or manual JWS;
// instead test Verify with bad token
_, err := Verify("not-a-jws", VerifyOptions{})
if err == nil {
t.Fatal("expected error for invalid JWS")
}
}
func TestVerify_Expired(t *testing.T) {
key, _ := GenerateKey()
now := time.Now()
payload := &Payload{
Iss: "iss",
Aud: []string{"verifier"},
Iat: now.Add(-1 * time.Hour).Unix(),
Exp: now.Add(-1 * time.Minute).Unix(),
Jti: "jti-exp",
ExecAct: "act",
Par: []string{},
Pol: "pol",
PolDecision: PolDecisionApproved,
}
compact, _ := Create(payload, key, CreateOptions{KeyID: "kid"})
resolver := func(kid string) (*ecdsa.PublicKey, error) {
if kid == "kid" {
return &key.PublicKey, nil
}
return nil, nil
}
_, err := Verify(compact, VerifyOptions{
VerifierID: "verifier",
ResolveKey: resolver,
Now: now,
})
if err == nil {
t.Fatal("expected error for expired token")
}
}
func TestVerify_Replay(t *testing.T) {
key, _ := GenerateKey()
now := time.Now()
payload := &Payload{
Iss: "iss",
Aud: []string{"v"},
Iat: now.Unix(),
Exp: now.Add(time.Hour).Unix(),
Jti: "jti-replay",
ExecAct: "act",
Par: []string{},
Pol: "p",
PolDecision: PolDecisionApproved,
}
compact, _ := Create(payload, key, CreateOptions{KeyID: "kid"})
resolver := func(kid string) (*ecdsa.PublicKey, error) {
if kid == "kid" {
return &key.PublicKey, nil
}
return nil, nil
}
opts := VerifyOptions{
VerifierID: "v",
ResolveKey: resolver,
Now: now,
JTISeen: func(jti string) bool { return jti == "jti-replay" },
}
_, err := Verify(compact, opts)
if err == nil {
t.Fatal("expected error for replay")
}
}
func TestDefaultVerifyOptions(t *testing.T) {
opts := DefaultVerifyOptions()
if opts.IATMaxAge != 15*time.Minute {
t.Errorf("IATMaxAge: got %v", opts.IATMaxAge)
}
if opts.DAG.ClockSkewTolerance != DefaultClockSkewTolerance {
t.Errorf("DAG: got %d", opts.DAG.ClockSkewTolerance)
}
}
func TestVerify_WITSubjectMismatch(t *testing.T) {
key, _ := GenerateKey()
now := time.Now()
payload := &Payload{
Iss: "iss", Aud: []string{"v"}, Iat: now.Unix(), Exp: now.Add(time.Hour).Unix(),
Jti: "jti-wit", ExecAct: "act", Par: []string{}, Pol: "p", PolDecision: PolDecisionApproved,
}
compact, _ := Create(payload, key, CreateOptions{KeyID: "kid"})
resolver := func(kid string) (*ecdsa.PublicKey, error) {
if kid == "kid" {
return &key.PublicKey, nil
}
return nil, nil
}
_, err := Verify(compact, VerifyOptions{
VerifierID: "v", ResolveKey: resolver, Now: now, WITSubject: "other-iss",
})
if err == nil {
t.Fatal("expected error for WIT subject mismatch")
}
}
func TestVerify_IATTooFarPast(t *testing.T) {
key, _ := GenerateKey()
now := time.Now()
payload := &Payload{
Iss: "iss", Aud: []string{"v"}, Iat: now.Add(-1 * time.Hour).Unix(), Exp: now.Add(time.Hour).Unix(),
Jti: "jti-iat", ExecAct: "act", Par: []string{}, Pol: "p", PolDecision: PolDecisionApproved,
}
compact, _ := Create(payload, key, CreateOptions{KeyID: "kid"})
resolver := func(kid string) (*ecdsa.PublicKey, error) {
if kid == "kid" {
return &key.PublicKey, nil
}
return nil, nil
}
_, err := Verify(compact, VerifyOptions{
VerifierID: "v", ResolveKey: resolver, Now: now, IATMaxAge: 30 * time.Minute,
})
if err == nil {
t.Fatal("expected error for iat too far in past")
}
}
func TestVerify_IATInFuture(t *testing.T) {
key, _ := GenerateKey()
now := time.Now()
payload := &Payload{
Iss: "iss", Aud: []string{"v"}, Iat: now.Add(60 * time.Second).Unix(), Exp: now.Add(2 * time.Hour).Unix(),
Jti: "jti-fut", ExecAct: "act", Par: []string{}, Pol: "p", PolDecision: PolDecisionApproved,
}
compact, _ := Create(payload, key, CreateOptions{KeyID: "kid"})
resolver := func(kid string) (*ecdsa.PublicKey, error) {
if kid == "kid" {
return &key.PublicKey, nil
}
return nil, nil
}
_, err := Verify(compact, VerifyOptions{
VerifierID: "v", ResolveKey: resolver, Now: now, IATMaxFuture: 30 * time.Second,
})
if err == nil {
t.Fatal("expected error for iat in future")
}
}
func TestVerify_ResolveKeyError(t *testing.T) {
key, _ := GenerateKey()
now := time.Now()
payload := &Payload{
Iss: "iss", Aud: []string{"v"}, Iat: now.Unix(), Exp: now.Add(time.Hour).Unix(),
Jti: "jti-err", ExecAct: "act", Par: []string{}, Pol: "p", PolDecision: PolDecisionApproved,
}
compact, _ := Create(payload, key, CreateOptions{KeyID: "kid"})
resolver := func(kid string) (*ecdsa.PublicKey, error) {
return nil, errors.New("key lookup failed")
}
_, err := Verify(compact, VerifyOptions{
VerifierID: "v", ResolveKey: resolver, Now: now,
})
if err == nil {
t.Fatal("expected error from ResolveKey")
}
}
func TestVerify_WithDAG(t *testing.T) {
key, _ := GenerateKey()
ledger := NewMemoryLedger()
now := time.Now()
root := &Payload{
Iss: "iss", Aud: []string{"v"}, Iat: now.Unix(), Exp: now.Add(time.Hour).Unix(),
Jti: "jti-root", ExecAct: "act", Par: []string{}, Pol: "p", PolDecision: PolDecisionApproved,
}
compactRoot, _ := Create(root, key, CreateOptions{KeyID: "kid"})
resolver := func(kid string) (*ecdsa.PublicKey, error) {
if kid == "kid" {
return &key.PublicKey, nil
}
return nil, nil
}
opts := VerifyOptions{
VerifierID: "v", ResolveKey: resolver, Store: ledger, Now: now,
}
parsed, err := Verify(compactRoot, opts)
if err != nil {
t.Fatal(err)
}
_, _ = ledger.Append(compactRoot, parsed.Payload)
child := &Payload{
Iss: "iss", Aud: []string{"v"}, Iat: now.Unix() + 1, Exp: now.Add(time.Hour).Unix(),
Jti: "jti-child", ExecAct: "act2", Par: []string{"jti-root"}, Pol: "p", PolDecision: PolDecisionApproved,
}
compactChild, _ := Create(child, key, CreateOptions{KeyID: "kid"})
parsed2, err := Verify(compactChild, opts)
if err != nil {
t.Fatal(err)
}
if parsed2.Payload.Jti != "jti-child" {
t.Errorf("got %q", parsed2.Payload.Jti)
}
}

7
refimpl/go-lang/go.mod Normal file
View File

@@ -0,0 +1,7 @@
module github.com/nennemann/ect-refimpl/go-lang
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-lang/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 @@
{"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","exec_act":"recommend_treatment","par":[],"pol":"clinical_reasoning_policy_v2","pol_decision":"approved"}