diff --git a/refimpl/IMPROVEMENTS.md b/refimpl/IMPROVEMENTS.md index 0aab495..d6753ba 100644 --- a/refimpl/IMPROVEMENTS.md +++ b/refimpl/IMPROVEMENTS.md @@ -61,15 +61,15 @@ Suggestions that could make the implementations more robust, spec-strict, or pro --- -## 6. **draft-01 migration** (NOT YET IMPLEMENTED) +## 6. **draft-01 migration** (PARTIALLY IMPLEMENTED) -The refimpl was built against draft-nennemann-wimse-ect-00. The -01 draft introduced breaking changes that need to be reflected: +The refimpl was built against draft-nennemann-wimse-ect-00. The -01 draft introduced breaking changes: -- **Rename `par` to `pred`**: The predecessor claim was renamed. Update struct fields, JSON tags, serialization/deserialization, tests, and testdata. -- **Remove `pol` and `pol_decision`**: Policy claims were removed from the core spec. Deployments should use `ect_ext` for domain-specific claims like policy decisions. -- **Remove `sub`**: The `sub` claim is not part of the ECT specification. Remove from types and examples. -- **Update `typ` default**: Prefer `exec+jwt` over `wimse-exec+jwt`. Both must be accepted for backward compatibility. +- **Rename `par` to `pred`**: ✅ **Done.** Struct fields, JSON tags, serialization/deserialization, tests, testdata, READMEs updated in both Go and Python. +- **Remove `pol` and `pol_decision`**: ✅ **Done.** Policy claims removed from core Payload. DAG policy checks now read from `ect_ext`. Tests and demos updated to use ext. +- **Remove `sub`**: ✅ **Done.** Removed from Payload struct (Go) and dataclass (Python). Create no longer defaults sub=iss. +- **Update `typ` default**: ✅ **Done.** `exec+jwt` is now preferred; `wimse-exec+jwt` accepted for backward compat. Verify checks both (constant-time). +- **Update `MaxParLength` naming**: ✅ **Done.** Renamed to `MaxPredLength` / `max_pred_length` everywhere. - **Add L1 support**: The -01 draft introduces unsigned JSON ECTs (Level 1). The refimpl currently only supports L2 (signed JWS). - **Add L3 support**: The -01 draft introduces audit ledger requirements for Level 3. The existing in-memory ledger needs hash chain and receipt support. -- **Update `MaxParLength` naming**: Rename to `MaxPredLength` to match the new claim name. - **Update hash format**: The -01 draft specifies SHA-256 base64url without algorithm prefix (no `sha-256:` prefix), consistent with RFC 9449. diff --git a/refimpl/README.md b/refimpl/README.md index ea16715..f67e740 100644 --- a/refimpl/README.md +++ b/refimpl/README.md @@ -1,16 +1,16 @@ # WIMSE Execution Context Tokens — Reference Implementations -> **Note**: These reference implementations were built against **draft-nennemann-wimse-ect-00**. -> The current draft (**-01**) introduced several claim name changes and structural updates: +> These reference implementations are aligned with **draft-nennemann-wimse-ect-01**. > -> | -00 (refimpl) | -01 (current draft) | Notes | -> |---------------|---------------------|-------| -> | `par` | `pred` | Predecessor task IDs | +> The following claim name changes from -00 have been applied: +> +> | -00 (previous) | -01 (current) | Notes | +> |----------------|---------------|-------| +> | `par` | `pred` | Predecessor task IDs | > | `pol`, `pol_decision` | removed (use `ect_ext`) | Policy claims moved to extension object | -> | `sub` | not defined | Standard JWT claim, not part of ECT spec | +> | `sub` | not defined | Standard JWT claim, not part of ECT spec | > | `typ: wimse-exec+jwt` | `typ: exec+jwt` (preferred) | Both accepted for backward compat | -> -> The refimpl update to -01 is tracked in IMPROVEMENTS.md. +> | `MaxParLength` | `MaxPredLength` | Renamed to match `pred` claim | This directory contains **reference implementations** of Execution Context Tokens (ECTs) for the WIMSE (Workload Identity in Multi System Environments) draft. Each refimpl provides ECT creation, verification, DAG validation, and an in-memory audit ledger. @@ -26,7 +26,7 @@ This directory contains **reference implementations** of Execution Context Token - **ECT format**: JWT (JWS Compact Serialization) with required/optional claims per the spec. - **Creation**: Build and sign ECTs with ES256; `kid` and `typ` in the JOSE header. - **Verification**: Full verification procedure (parse, typ/alg, key resolution, signature, claims, optional DAG). -- **DAG validation**: Uniqueness, parent existence, temporal ordering, acyclicity, parent policy. +- **DAG validation**: Uniqueness, predecessor existence, temporal ordering, acyclicity, predecessor policy. - **Ledger**: Interface plus in-memory append-only store. No WIT/WPT issuance or full WIMSE stack; refimpls use key resolution only. Suitable for conformance testing and as a template for production integrations. @@ -54,7 +54,7 @@ python3 -m pytest tests/ -v ## Specification - **Current draft**: `draft-nennemann-wimse-ect-01` -- **Refimpl implements**: `-00` claim names (see migration note above) +- **Refimpl implements**: `-01` claim names ## License diff --git a/refimpl/go-lang/README.md b/refimpl/go-lang/README.md index 4065530..eef25fa 100644 --- a/refimpl/go-lang/README.md +++ b/refimpl/go-lang/README.md @@ -1,6 +1,6 @@ # 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). +Go reference implementation of [Execution Context Tokens (ECTs)](../../draft-nennemann-wimse-execution-context-01.txt) for WIMSE. Implements ECT creation (ES256), verification (Section 7), DAG validation (Section 6), and an in-memory audit ledger (Section 9). ## Layout @@ -43,9 +43,11 @@ payload := &ect.Payload{ 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, + Pred: []string{}, + Ext: map[string]interface{}{ + "pol": "policy_v1", + "pol_decision": "approved", + }, } compact, err := ect.Create(payload, key, cfg.CreateOptions("agent-a-key")) @@ -84,6 +86,16 @@ 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. +## draft-01 claim changes + +| -00 (previous) | -01 (current) | Notes | +|----------------|---------------|-------| +| `par` | `pred` | Predecessor task IDs | +| `pol`, `pol_decision` | removed (use `ect_ext`) | Policy claims moved to extension object | +| `sub` | not defined | Standard JWT claim, not part of ECT spec | +| `typ: wimse-exec+jwt` | `typ: exec+jwt` (preferred) | Both accepted for backward compat | +| `MaxParLength` | `MaxPredLength` | Renamed to match `pred` claim | + ## Production configuration (environment) | Variable | Default | Description | @@ -96,7 +108,7 @@ Unit tests are in `ect/*_test.go`. Coverage target: **~90%** (run `go test ./ect ### 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. +`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 diff --git a/refimpl/go-lang/cmd/demo/main.go b/refimpl/go-lang/cmd/demo/main.go index ff748e9..81cbb0a 100644 --- a/refimpl/go-lang/cmd/demo/main.go +++ b/refimpl/go-lang/cmd/demo/main.go @@ -27,16 +27,18 @@ func main() { // 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, + 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", + Pred: []string{}, + Ext: map[string]interface{}{ + "pol": "spec_review_policy_v2", + "pol_decision": "approved", + }, } ectA, err := ect.Create(payloadA, keyA, ect.CreateOptions{KeyID: kidA}) if err != nil { @@ -69,26 +71,28 @@ func main() { } fmt.Println("Agent B verified root ECT and appended to ledger") - // 3) Agent B creates child ECT (par contains parent jti values per spec) + // 3) Agent B creates child ECT (pred contains predecessor 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, + 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", + Pred: []string{"550e8400-e29b-41d4-a716-446655440001"}, + Ext: map[string]interface{}{ + "pol": "coding_standards_v3", + "pol_decision": "approved", + }, } 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])") + fmt.Println("Agent B created child ECT (jti=550e8400-...002, implement_module, pred=[predecessor jti])") // 4) Verify child ECT with DAG (ledger has task-001) resolverB := ect.KeyResolver(func(kid string) (*ecdsa.PublicKey, error) { diff --git a/refimpl/go-lang/ect/create.go b/refimpl/go-lang/ect/create.go index 04d6afc..7db99fb 100644 --- a/refimpl/go-lang/ect/create.go +++ b/refimpl/go-lang/ect/create.go @@ -21,8 +21,8 @@ type CreateOptions struct { 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 + // MaxPredLength is the max number of predecessor references (0 = no limit; recommended 100). + MaxPredLength int } // DefaultCreateOptions returns recommended defaults. @@ -53,11 +53,8 @@ func Create(payload *Payload, privateKey *ecdsa.PrivateKey, opts CreateOptions) } payload.Exp = now.Add(opts.DefaultExpiry).Unix() } - if payload.Sub == "" { - payload.Sub = payload.Iss - } - if payload.Par == nil { - payload.Par = []string{} + if payload.Pred == nil { + payload.Pred = []string{} } if err := validatePayloadForCreate(payload, opts); err != nil { @@ -110,8 +107,8 @@ func validatePayloadForCreate(p *Payload, opts CreateOptions) error { return ErrInvalidWID } } - if opts.MaxParLength > 0 && len(p.Par) > opts.MaxParLength { - return ErrParLength + if opts.MaxPredLength > 0 && len(p.Pred) > opts.MaxPredLength { + return ErrPredLength } if p.InpHash != "" { if err := ValidateHashFormat(p.InpHash); err != nil { @@ -126,15 +123,6 @@ func validatePayloadForCreate(p *Payload, opts CreateOptions) error { 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 { diff --git a/refimpl/go-lang/ect/create_test.go b/refimpl/go-lang/ect/create_test.go index 77e2d2f..cf1d5e5 100644 --- a/refimpl/go-lang/ect/create_test.go +++ b/refimpl/go-lang/ect/create_test.go @@ -15,15 +15,13 @@ func TestCreateRoundtrip(t *testing.T) { } 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, + 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", + Pred: []string{}, } compact, err := Create(payload, key, CreateOptions{KeyID: "agent-a-key-1"}) if err != nil { @@ -68,7 +66,7 @@ func TestDefaultCreateOptions(t *testing.T) { 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} + payload := &Payload{Iss: "i", Aud: []string{"a"}, Jti: "j", ExecAct: "e", Pred: []string{}, Iat: 1, Exp: 2} if _, err := Create(nil, key, CreateOptions{KeyID: "k"}); err == nil { t.Error("expected error for nil payload") } @@ -85,7 +83,7 @@ func TestCreate_OptionalPol(t *testing.T) { 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{}, + Jti: "jti-nopol", ExecAct: "act", Pred: []string{}, } compact, err := Create(payload, key, CreateOptions{KeyID: "kid"}) if err != nil { @@ -100,7 +98,7 @@ 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{}, + Jti: "jti-z", ExecAct: "e", Pred: []string{}, } _, err := Create(payload, key, CreateOptions{KeyID: "kid", DefaultExpiry: 5 * time.Minute}) if err != nil { @@ -115,7 +113,7 @@ 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{}, + Jti: "j", ExecAct: "e", Pred: []string{}, Ext: map[string]interface{}{"compensation_reason": "rollback", "compensation_required": false}, } _, err := Create(payload, key, CreateOptions{KeyID: "k"}) @@ -130,12 +128,10 @@ func TestCreate_ValidationErrors(t *testing.T) { 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}}, + {"missing iss", &Payload{Iss: "", Aud: []string{"a"}, Jti: "j", ExecAct: "e", Pred: []string{}, Iat: 1, Exp: 2}}, + {"missing aud", &Payload{Iss: "i", Aud: nil, Jti: "j", ExecAct: "e", Pred: []string{}, Iat: 1, Exp: 2}}, + {"missing jti", &Payload{Iss: "i", Aud: []string{"a"}, Jti: "", ExecAct: "e", Pred: []string{}, Iat: 1, Exp: 2}}, + {"missing exec_act", &Payload{Iss: "i", Aud: []string{"a"}, Jti: "j", ExecAct: "", Pred: []string{}, Iat: 1, Exp: 2}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/refimpl/go-lang/ect/dag.go b/refimpl/go-lang/ect/dag.go index 1bbf428..fcb50fd 100644 --- a/refimpl/go-lang/ect/dag.go +++ b/refimpl/go-lang/ect/dag.go @@ -24,7 +24,7 @@ type ECTStore interface { type DAGConfig struct { ClockSkewTolerance int // seconds; recommended 30 MaxAncestorLimit int // recommended 10000 - MaxParLength int // max par length (0 = no limit; recommended 100) + MaxPredLength int // max pred length (0 = no limit; recommended 100) } // DefaultDAGConfig returns recommended defaults. @@ -32,7 +32,7 @@ func DefaultDAGConfig() DAGConfig { return DAGConfig{ ClockSkewTolerance: DefaultClockSkewTolerance, MaxAncestorLimit: DefaultMaxAncestorLimit, - MaxParLength: DefaultMaxParLength, + MaxPredLength: DefaultMaxPredLength, } } @@ -48,8 +48,8 @@ func ValidateDAG(ect *Payload, store ECTStore, cfg DAGConfig) error { if cfg.MaxAncestorLimit <= 0 { cfg.MaxAncestorLimit = DefaultMaxAncestorLimit } - if cfg.MaxParLength > 0 && len(ect.Par) > cfg.MaxParLength { - return ErrParLength + if cfg.MaxPredLength > 0 && len(ect.Pred) > cfg.MaxPredLength { + return ErrPredLength } // 1. Task ID Uniqueness (task id = jti per spec) @@ -57,31 +57,33 @@ func ValidateDAG(ect *Payload, store ECTStore, cfg DAGConfig) error { 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) + // 2. Predecessor Existence and 3. Temporal Ordering + for _, predID := range ect.Pred { + pred := store.GetByTid(predID) + if pred == nil { + return fmt.Errorf("ect: predecessor task not found: %s", predID) } - // 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) + // pred.iat < child.iat + clock_skew_tolerance + if pred.Iat >= ect.Iat+int64(cfg.ClockSkewTolerance) { + return fmt.Errorf("ect: predecessor task not earlier than current: %s", predID) } } // 4. Acyclicity (and depth limit) visited := make(map[string]struct{}) - if hasCycle(ect.Jti, ect.Par, store, visited, cfg.MaxAncestorLimit) { + if hasCycle(ect.Jti, ect.Pred, 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") + // 5. Predecessor Policy Decision (only when predecessor has policy claims in ext per -01) + for _, predID := range ect.Pred { + pred := store.GetByTid(predID) + if pred != nil && pred.HasPolicyClaims() { + polDec := pred.PolDecision() + if polDec == "rejected" || polDec == "pending_human_review" { + if !ect.CompensationRequired() { + return errors.New("ect: predecessor has non-approved pol_decision; current ECT must be compensation/remediation or have ext.compensation_required true") + } } } } @@ -89,23 +91,23 @@ func ValidateDAG(ect *Payload, store ECTStore, cfg DAGConfig) error { return nil } -// hasCycle returns true if following par from the given parent IDs leads back to targetTid +// hasCycle returns true if following pred from the given predecessor 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 { +func hasCycle(targetTid string, predIDs []string, store ECTStore, visited map[string]struct{}, maxDepth int) bool { if len(visited) >= maxDepth { return true } - for _, parentID := range parentIDs { - if parentID == targetTid { + for _, predID := range predIDs { + if predID == targetTid { return true } - if _, ok := visited[parentID]; ok { + if _, ok := visited[predID]; ok { continue } - visited[parentID] = struct{}{} - parent := store.GetByTid(parentID) - if parent != nil { - if hasCycle(targetTid, parent.Par, store, visited, maxDepth) { + visited[predID] = struct{}{} + pred := store.GetByTid(predID) + if pred != nil { + if hasCycle(targetTid, pred.Pred, store, visited, maxDepth) { return true } } diff --git a/refimpl/go-lang/ect/dag_test.go b/refimpl/go-lang/ect/dag_test.go index 5e15bcb..0f36271 100644 --- a/refimpl/go-lang/ect/dag_test.go +++ b/refimpl/go-lang/ect/dag_test.go @@ -8,10 +8,9 @@ import ( func TestValidateDAG_Root(t *testing.T) { store := NewMemoryLedger() payload := &Payload{ - Jti: "jti-001", - Wid: "wf-1", - Par: []string{}, - PolDecision: PolDecisionApproved, + Jti: "jti-001", + Wid: "wf-1", + Pred: []string{}, } err := ValidateDAG(payload, store, DefaultDAGConfig()) if err != nil { @@ -21,23 +20,22 @@ func TestValidateDAG_Root(t *testing.T) { 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} + _, _ = store.Append("dummy-jws", &Payload{Jti: "jti-001", Wid: "wf-1", Pred: []string{}}) + payload := &Payload{Jti: "jti-001", Wid: "wf-1", Pred: []string{}} err := ValidateDAG(payload, store, DefaultDAGConfig()) if err == nil { t.Fatal("expected error for duplicate jti") } } -func TestValidateDAG_ParentExists(t *testing.T) { +func TestValidateDAG_PredExists(t *testing.T) { store := NewMemoryLedger() - _, _ = store.Append("jws1", &Payload{Jti: "jti-001", Wid: "wf-1", Par: []string{}, PolDecision: PolDecisionApproved, Iat: time.Now().Unix() - 60}) + _, _ = store.Append("jws1", &Payload{Jti: "jti-001", Wid: "wf-1", Pred: []string{}, Iat: time.Now().Unix() - 60}) payload := &Payload{ - Jti: "jti-002", - Wid: "wf-1", - Par: []string{"jti-001"}, - PolDecision: PolDecisionApproved, - Iat: time.Now().Unix(), + Jti: "jti-002", + Wid: "wf-1", + Pred: []string{"jti-001"}, + Iat: time.Now().Unix(), } err := ValidateDAG(payload, store, DefaultDAGConfig()) if err != nil { @@ -45,17 +43,16 @@ func TestValidateDAG_ParentExists(t *testing.T) { } } -func TestValidateDAG_ParentNotFound(t *testing.T) { +func TestValidateDAG_PredNotFound(t *testing.T) { store := NewMemoryLedger() payload := &Payload{ - Jti: "jti-002", - Par: []string{"jti-missing"}, - PolDecision: PolDecisionApproved, - Iat: time.Now().Unix(), + Jti: "jti-002", + Pred: []string{"jti-missing"}, + Iat: time.Now().Unix(), } err := ValidateDAG(payload, store, DefaultDAGConfig()) if err == nil { - t.Fatal("expected error when parent not found") + t.Fatal("expected error when predecessor not found") } } @@ -63,10 +60,10 @@ 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}) + _, _ = store.Append("jws1", &Payload{Jti: "jti-1", Wid: "wf", Pred: []string{}, Iat: now - 100}) + _, _ = store.Append("jws2", &Payload{Jti: "jti-2", Wid: "wf", Pred: []string{"jti-1"}, Iat: now - 50}) cfg := DAGConfig{ClockSkewTolerance: DefaultClockSkewTolerance, MaxAncestorLimit: 2} - payload := &Payload{Jti: "jti-3", Wid: "wf", Par: []string{"jti-2"}, PolDecision: PolDecisionApproved, Iat: now} + payload := &Payload{Jti: "jti-3", Wid: "wf", Pred: []string{"jti-2"}, Iat: now} err := ValidateDAG(payload, store, cfg) if err == nil { t.Fatal("expected error when ancestor limit exceeded") @@ -74,7 +71,7 @@ func TestValidateDAG_DepthLimit(t *testing.T) { } func TestValidateDAG_StoreNil(t *testing.T) { - payload := &Payload{Jti: "j1", Par: []string{}, PolDecision: PolDecisionApproved, Iat: time.Now().Unix()} + payload := &Payload{Jti: "j1", Pred: []string{}, Iat: time.Now().Unix()} err := ValidateDAG(payload, nil, DefaultDAGConfig()) if err == nil { t.Fatal("expected error when store is nil") @@ -84,53 +81,56 @@ func TestValidateDAG_StoreNil(t *testing.T) { 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} + _, _ = store.Append("jws1", &Payload{Jti: "jti-1", Wid: "wf", Pred: []string{}, Iat: now}) + // child has iat after pred: valid + payload := &Payload{Jti: "jti-2", Wid: "wf", Pred: []string{"jti-1"}, 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} + // pred.iat >= child.iat + skew: pred at now+50, child at now+10, skew 30 => 50 >= 40 => invalid + _, _ = store.Append("jws2", &Payload{Jti: "jti-1b", Wid: "wf", Pred: []string{}, Iat: now + 50}) + payload2 := &Payload{Jti: "jti-2b", Wid: "wf", Pred: []string{"jti-1b"}, 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") + t.Fatal("expected error when predecessor not earlier than child") } } func TestValidateDAG_DirectCycle(t *testing.T) { - // par contains own jti (direct self-reference) -> parent not found + // pred contains own jti (direct self-reference) -> predecessor not found store := NewMemoryLedger() now := time.Now().Unix() - payload := &Payload{Jti: "jti-self", Wid: "wf", Par: []string{"jti-self"}, PolDecision: PolDecisionApproved, Iat: now} + payload := &Payload{Jti: "jti-self", Wid: "wf", Pred: []string{"jti-self"}, Iat: now} err := ValidateDAG(payload, store, DefaultDAGConfig()) if err == nil { - t.Fatal("expected error for direct cycle (par contains self)") + t.Fatal("expected error for direct cycle (pred contains self)") } } func TestValidateDAG_hasCycle_visitedContinue(t *testing.T) { - // par has duplicate parent ID so we hit "if _, ok := visited[parentID]; ok { continue }" + // pred has duplicate predecessor ID so we hit "if _, ok := visited[predID]; 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} + _, _ = store.Append("jws1", &Payload{Jti: "jti-a", Wid: "wf", Pred: []string{}, Iat: now - 10}) + payload := &Payload{Jti: "jti-b", Wid: "wf", Pred: []string{"jti-a", "jti-a"}, Iat: now} err := ValidateDAG(payload, store, DefaultDAGConfig()) if err != nil { t.Fatal(err) } } -func TestValidateDAG_ParentPolicyRejected_RequiresCompensation(t *testing.T) { +func TestValidateDAG_PredPolicyRejected_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} + _, _ = store.Append("jws1", &Payload{ + Jti: "jti-rej", Wid: "wf", Pred: []string{}, Iat: now - 60, + Ext: map[string]interface{}{"pol": "p", "pol_decision": "rejected"}, + }) + payload := &Payload{Jti: "jti-child", Wid: "wf", Pred: []string{"jti-rej"}, Iat: now} err := ValidateDAG(payload, store, DefaultDAGConfig()) if err == nil { - t.Fatal("expected error when parent rejected and no compensation") + t.Fatal("expected error when predecessor rejected and no compensation") } payload.Ext = map[string]interface{}{"compensation_required": true} err = ValidateDAG(payload, store, DefaultDAGConfig()) diff --git a/refimpl/go-lang/ect/errors.go b/refimpl/go-lang/ect/errors.go index 3142444..5001baa 100644 --- a/refimpl/go-lang/ect/errors.go +++ b/refimpl/go-lang/ect/errors.go @@ -15,15 +15,13 @@ var ( 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") + ErrMissingClaims = errors.New("ect: missing required claims (jti, exec_act, pred)") 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") + ErrPredLength = errors.New("ect: pred exceeds max length") ErrHashFormat = errors.New("ect: inp_hash/out_hash must be algorithm:base64url (e.g. sha-256:...)") ) diff --git a/refimpl/go-lang/ect/ledger.go b/refimpl/go-lang/ect/ledger.go index 588dc47..fa1da68 100644 --- a/refimpl/go-lang/ect/ledger.go +++ b/refimpl/go-lang/ect/ledger.go @@ -12,7 +12,7 @@ type LedgerEntry struct { TaskID string `json:"task_id"` AgentID string `json:"agent_id"` Action string `json:"action"` - Parents []string `json:"parents"` + Predecessors []string `json:"predecessors"` ECTJWS string `json:"ect_jws"` SignatureVerified bool `json:"signature_verified"` VerificationTime time.Time `json:"verification_timestamp"` @@ -66,7 +66,7 @@ func (m *MemoryLedger) Append(ectJWS string, payload *Payload) (int64, error) { TaskID: payload.Jti, // task id = jti per spec AgentID: payload.Iss, Action: payload.ExecAct, - Parents: append([]string(nil), payload.Par...), + Predecessors: append([]string(nil), payload.Pred...), ECTJWS: ectJWS, SignatureVerified: true, VerificationTime: time.Now().UTC(), diff --git a/refimpl/go-lang/ect/ledger_test.go b/refimpl/go-lang/ect/ledger_test.go index d8e26c1..5184add 100644 --- a/refimpl/go-lang/ect/ledger_test.go +++ b/refimpl/go-lang/ect/ledger_test.go @@ -7,7 +7,7 @@ import ( 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()} + p := &Payload{Jti: "jti-1", Iss: "iss", ExecAct: "act", Pred: []string{}, Iat: time.Now().Unix(), Exp: time.Now().Add(time.Hour).Unix()} seq, err := m.Append("jws1", p) if err != nil { t.Fatal(err) @@ -23,7 +23,7 @@ func TestMemoryLedger_AppendAndGet(t *testing.T) { func TestMemoryLedger_ErrTaskIDExists(t *testing.T) { m := NewMemoryLedger() - p := &Payload{Jti: "jti-dup", Iss: "i", ExecAct: "e", Par: []string{}, Iat: 1, Exp: 2} + p := &Payload{Jti: "jti-dup", Iss: "i", ExecAct: "e", Pred: []string{}, Iat: 1, Exp: 2} _, _ = m.Append("jws1", p) _, err := m.Append("jws2", p) if err != ErrTaskIDExists { @@ -33,7 +33,7 @@ func TestMemoryLedger_ErrTaskIDExists(t *testing.T) { func TestMemoryLedger_ContainsWid(t *testing.T) { m := NewMemoryLedger() - p := &Payload{Jti: "j1", Wid: "wf1", Iss: "i", ExecAct: "e", Par: []string{}, Iat: 1, Exp: 2} + p := &Payload{Jti: "j1", Wid: "wf1", Iss: "i", ExecAct: "e", Pred: []string{}, Iat: 1, Exp: 2} _, _ = m.Append("jws", p) if !m.Contains("j1", "") { t.Error("Contains(j1, \"\") should be true") diff --git a/refimpl/go-lang/ect/types.go b/refimpl/go-lang/ect/types.go index 6eafafb..baae0e9 100644 --- a/refimpl/go-lang/ect/types.go +++ b/refimpl/go-lang/ect/types.go @@ -1,24 +1,20 @@ // Package ect implements Execution Context Tokens (ECTs) per -// draft-nennemann-wimse-execution-context-00. +// draft-nennemann-wimse-execution-context-01. package ect import "time" -// ECTType is the JOSE typ value for ECTs. -const ECTType = "wimse-exec+jwt" - -// PolDecision values per Section 4.2.3. +// ECTType is the preferred JOSE typ value for ECTs per -01. +// ECTTypeLegacy is accepted for backward compatibility with -00. const ( - PolDecisionApproved = "approved" - PolDecisionRejected = "rejected" - PolDecisionPendingHumanReview = "pending_human_review" + ECTType = "exec+jwt" + ECTTypeLegacy = "wimse-exec+jwt" ) // 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 @@ -26,15 +22,9 @@ type Payload struct { // 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"` + Wid string `json:"wid,omitempty"` // OPTIONAL: workflow ID, UUID + ExecAct string `json:"exec_act"` // REQUIRED + Pred []string `json:"pred"` // REQUIRED: predecessor jti values (renamed from par in -01) // Data integrity (Section 4.2.4) InpHash string `json:"inp_hash,omitempty"` @@ -42,8 +32,9 @@ type Payload struct { 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"` + // witnessed_by, inp_classification, compensation_required, compensation_reason, + // and domain-specific claims like pol, pol_decision (moved from core in -01). + Ext map[string]interface{} `json:"ect_ext,omitempty"` } // Audience is aud claim: string or array of strings. @@ -62,11 +53,6 @@ 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 { @@ -96,7 +82,21 @@ func (p *Payload) CompensationRequired() bool { return v } -// HasPolicyClaims returns true if both pol and pol_decision are present (optional pair per spec). +// HasPolicyClaims returns true if both pol and pol_decision are present in ext (per -01, moved to extension). func (p *Payload) HasPolicyClaims() bool { - return p.Pol != "" && p.PolDecision != "" + if p.Ext == nil { + return false + } + pol, _ := p.Ext["pol"].(string) + polDec, _ := p.Ext["pol_decision"].(string) + return pol != "" && polDec != "" +} + +// PolDecision returns the pol_decision value from ext, or empty string. +func (p *Payload) PolDecision() string { + if p.Ext == nil { + return "" + } + v, _ := p.Ext["pol_decision"].(string) + return v } diff --git a/refimpl/go-lang/ect/types_test.go b/refimpl/go-lang/ect/types_test.go index 4ab5a8d..926e063 100644 --- a/refimpl/go-lang/ect/types_test.go +++ b/refimpl/go-lang/ect/types_test.go @@ -59,18 +59,6 @@ func TestAudience_UnmarshalJSON_invalid(t *testing.T) { } } -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") { @@ -104,12 +92,27 @@ func TestPayload_CompensationRequired(t *testing.T) { } func TestPayload_HasPolicyClaims(t *testing.T) { - p := &Payload{Pol: "p", PolDecision: PolDecisionApproved} + p := &Payload{Ext: map[string]interface{}{"pol": "p", "pol_decision": "approved"}} if !p.HasPolicyClaims() { - t.Error("both pol and pol_decision set should have policy claims") + t.Error("both pol and pol_decision in ext should have policy claims") } - p.Pol = "" + p.Ext = map[string]interface{}{"pol_decision": "approved"} if p.HasPolicyClaims() { - t.Error("missing pol should not have policy claims") + t.Error("missing pol in ext should not have policy claims") + } + p.Ext = nil + if p.HasPolicyClaims() { + t.Error("nil ext should not have policy claims") + } +} + +func TestPayload_PolDecision(t *testing.T) { + p := &Payload{Ext: map[string]interface{}{"pol_decision": "rejected"}} + if p.PolDecision() != "rejected" { + t.Errorf("expected rejected, got %q", p.PolDecision()) + } + p.Ext = nil + if p.PolDecision() != "" { + t.Errorf("expected empty for nil ext, got %q", p.PolDecision()) } } diff --git a/refimpl/go-lang/ect/validate.go b/refimpl/go-lang/ect/validate.go index 2b16510..e29aa9b 100644 --- a/refimpl/go-lang/ect/validate.go +++ b/refimpl/go-lang/ect/validate.go @@ -13,8 +13,8 @@ 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 +// DefaultMaxPredLength is the recommended max number of predecessor references. +const DefaultMaxPredLength = 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}$`) diff --git a/refimpl/go-lang/ect/verify.go b/refimpl/go-lang/ect/verify.go index 7eea5f0..6acd941 100644 --- a/refimpl/go-lang/ect/verify.go +++ b/refimpl/go-lang/ect/verify.go @@ -44,8 +44,8 @@ type VerifyOptions struct { 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 + // MaxPredLength caps pred length (0 = no limit). Applied before DAG; DAG may also enforce via DAG.MaxPredLength. + MaxPredLength int // LogVerify if set is called after verification with jti and any error (for observability). LogVerify func(jti string, err error) } @@ -104,12 +104,13 @@ func Verify(compact string, opts VerifyOptions) (parsed *ParsedECT, err error) { sig := jws.Signatures[0] header := &sig.Header - // 2. typ must be wimse-exec+jwt (constant-time compare) + // 2. typ must be exec+jwt (preferred) or wimse-exec+jwt (legacy); constant-time compare typ, _ := header.ExtraHeaders["typ"].(string) if typ == "" { typ, _ = header.ExtraHeaders[jose.HeaderType].(string) } - if subtle.ConstantTimeCompare([]byte(typ), []byte(ECTType)) != 1 { + if subtle.ConstantTimeCompare([]byte(typ), []byte(ECTType)) != 1 && + subtle.ConstantTimeCompare([]byte(typ), []byte(ECTTypeLegacy)) != 1 { return nil, ErrInvalidTyp } @@ -182,15 +183,15 @@ func Verify(compact string, opts VerifyOptions) (parsed *ParsedECT, err error) { return nil, ErrIATInFuture } - // 12. Required claims present (jti, exec_act, par) + // 12. Required claims present (jti, exec_act, pred) if p.Jti == "" || p.ExecAct == "" { return nil, ErrMissingClaims } - if p.Par == nil { - p.Par = []string{} + if p.Pred == nil { + p.Pred = []string{} } - if opts.MaxParLength > 0 && len(p.Par) > opts.MaxParLength { - return nil, ErrParLength + if opts.MaxPredLength > 0 && len(p.Pred) > opts.MaxPredLength { + return nil, ErrPredLength } if opts.ValidateUUIDs { if !ValidUUID(p.Jti) { @@ -211,24 +212,14 @@ func Verify(compact string, opts VerifyOptions) (parsed *ParsedECT, err error) { } } - // 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 + // 13. DAG validation if opts.Store != nil { if err := ValidateDAG(&p, opts.Store, opts.DAG); err != nil { return nil, err } } - // 15. Replay (jti seen) + // 14. Replay (jti seen) if opts.JTISeen != nil && opts.JTISeen(p.Jti) { return nil, ErrReplay } diff --git a/refimpl/go-lang/ect/verify_test.go b/refimpl/go-lang/ect/verify_test.go index 5d0df8d..79f2505 100644 --- a/refimpl/go-lang/ect/verify_test.go +++ b/refimpl/go-lang/ect/verify_test.go @@ -11,15 +11,13 @@ 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, + Iss: "iss", + Aud: []string{"aud"}, + Iat: now.Unix(), + Exp: now.Add(time.Hour).Unix(), + Jti: "jti-parse", + ExecAct: "act", + Pred: []string{}, } compact, err := Create(payload, key, CreateOptions{KeyID: "kid"}) if err != nil { @@ -50,15 +48,13 @@ 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, + Iss: "iss", + Aud: []string{"verifier"}, + Iat: now.Add(-1 * time.Hour).Unix(), + Exp: now.Add(-1 * time.Minute).Unix(), + Jti: "jti-exp", + ExecAct: "act", + Pred: []string{}, } compact, _ := Create(payload, key, CreateOptions{KeyID: "kid"}) resolver := func(kid string) (*ecdsa.PublicKey, error) { @@ -81,15 +77,13 @@ 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, + Iss: "iss", + Aud: []string{"v"}, + Iat: now.Unix(), + Exp: now.Add(time.Hour).Unix(), + Jti: "jti-replay", + ExecAct: "act", + Pred: []string{}, } compact, _ := Create(payload, key, CreateOptions{KeyID: "kid"}) resolver := func(kid string) (*ecdsa.PublicKey, error) { @@ -125,7 +119,7 @@ func TestVerify_WITSubjectMismatch(t *testing.T) { 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, + Jti: "jti-wit", ExecAct: "act", Pred: []string{}, } compact, _ := Create(payload, key, CreateOptions{KeyID: "kid"}) resolver := func(kid string) (*ecdsa.PublicKey, error) { @@ -147,7 +141,7 @@ func TestVerify_IATTooFarPast(t *testing.T) { 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, + Jti: "jti-iat", ExecAct: "act", Pred: []string{}, } compact, _ := Create(payload, key, CreateOptions{KeyID: "kid"}) resolver := func(kid string) (*ecdsa.PublicKey, error) { @@ -169,7 +163,7 @@ func TestVerify_IATInFuture(t *testing.T) { 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, + Jti: "jti-fut", ExecAct: "act", Pred: []string{}, } compact, _ := Create(payload, key, CreateOptions{KeyID: "kid"}) resolver := func(kid string) (*ecdsa.PublicKey, error) { @@ -191,7 +185,7 @@ func TestVerify_ResolveKeyError(t *testing.T) { 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, + Jti: "jti-err", ExecAct: "act", Pred: []string{}, } compact, _ := Create(payload, key, CreateOptions{KeyID: "kid"}) resolver := func(kid string) (*ecdsa.PublicKey, error) { @@ -211,7 +205,7 @@ func TestVerify_WithDAG(t *testing.T) { 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, + Jti: "jti-root", ExecAct: "act", Pred: []string{}, } compactRoot, _ := Create(root, key, CreateOptions{KeyID: "kid"}) resolver := func(kid string) (*ecdsa.PublicKey, error) { @@ -230,7 +224,7 @@ func TestVerify_WithDAG(t *testing.T) { _, _ = 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, + Jti: "jti-child", ExecAct: "act2", Pred: []string{"jti-root"}, } compactChild, _ := Create(child, key, CreateOptions{KeyID: "kid"}) parsed2, err := Verify(compactChild, opts) diff --git a/refimpl/go-lang/testdata/valid_root_ect_payload.json b/refimpl/go-lang/testdata/valid_root_ect_payload.json index 8105b0e..46aa833 100644 --- a/refimpl/go-lang/testdata/valid_root_ect_payload.json +++ b/refimpl/go-lang/testdata/valid_root_ect_payload.json @@ -1 +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"} +{"iss":"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","pred":[],"ect_ext":{"pol":"clinical_reasoning_policy_v2","pol_decision":"approved"}} diff --git a/refimpl/python/README.md b/refimpl/python/README.md index 22dca70..8085692 100644 --- a/refimpl/python/README.md +++ b/refimpl/python/README.md @@ -1,6 +1,6 @@ # WIMSE ECT — Python Reference Implementation -Python 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). +Python reference implementation of [Execution Context Tokens (ECTs)](../../draft-nennemann-wimse-execution-context-01.txt) for WIMSE. Implements ECT creation (ES256), verification (Section 7), DAG validation (Section 6), and an in-memory audit ledger (Section 9). ## Layout @@ -42,7 +42,6 @@ from ect import ( verify, VerifyOptions, MemoryLedger, - POL_DECISION_APPROVED, ) cfg = load_config_from_env() @@ -54,9 +53,11 @@ payload = Payload( exp=int(time.time()) + 600, jti="550e8400-e29b-41d4-a716-446655440000", exec_act="review_spec", - par=[], - pol="policy_v1", - pol_decision=POL_DECISION_APPROVED, + pred=[], + ext={ + "pol": "policy_v1", + "pol_decision": "approved", + }, ) compact = create(payload, key, cfg.create_options("agent-a-key")) @@ -83,6 +84,16 @@ cd refimpl/python && python3 -m pytest tests/ -v Unit tests require **90% coverage** minimum (`pytest` is configured with `--cov-fail-under=90` in `pyproject.toml`). Install dev deps: `pip install -e ".[dev]"`. Uncovered lines are mainly abstract base methods and a few verify branches that need manually built tokens. +## draft-01 claim changes + +| -00 (previous) | -01 (current) | Notes | +|----------------|---------------|-------| +| `par` | `pred` | Predecessor task IDs | +| `pol`, `pol_decision` | removed (use `ect_ext`) | Policy claims moved to extension object | +| `sub` | not defined | Standard JWT claim, not part of ECT spec | +| `typ: wimse-exec+jwt` | `typ: exec+jwt` (preferred) | Both accepted for backward compat | +| `max_par_length` | `max_pred_length` | Renamed to match `pred` claim | + ## Production configuration (environment) Same env vars as the Go refimpl: `ECT_IAT_MAX_AGE_MINUTES`, `ECT_IAT_MAX_FUTURE_SEC`, `ECT_DEFAULT_EXPIRY_MIN`, `ECT_JTI_REPLAY_CACHE_SIZE`, `ECT_JTI_REPLAY_TTL_MIN`. diff --git a/refimpl/python/demo.py b/refimpl/python/demo.py index 4b9b5b2..0d6e37b 100644 --- a/refimpl/python/demo.py +++ b/refimpl/python/demo.py @@ -11,7 +11,6 @@ from ect import ( verify, VerifyOptions, MemoryLedger, - POL_DECISION_APPROVED, ) def main(): @@ -33,9 +32,11 @@ def main(): jti=root_jti, wid="wf-demo-001", exec_act="review_requirements_spec", - par=[], - pol="spec_review_policy_v2", - pol_decision=POL_DECISION_APPROVED, + pred=[], + ext={ + "pol": "spec_review_policy_v2", + "pol_decision": "approved", + }, ) ect_a = create(payload_a, key_a, CreateOptions(key_id=kid_a)) print("Agent A created root ECT (jti=550e8400-..., review_requirements_spec)") @@ -56,7 +57,7 @@ def main(): ledger.append(ect_a, parsed.payload) print("Agent B verified root ECT and appended to ledger") - # 3) Agent B creates child ECT (par contains parent jti values per spec) + # 3) Agent B creates child ECT (pred contains predecessor jti values per spec) key_b = generate_key() kid_b = "agent-b-key" child_jti = "550e8400-e29b-41d4-a716-446655440002" @@ -68,12 +69,14 @@ def main(): jti=child_jti, wid="wf-demo-001", exec_act="implement_module", - par=[root_jti], - pol="coding_standards_v3", - pol_decision=POL_DECISION_APPROVED, + pred=[root_jti], + ext={ + "pol": "coding_standards_v3", + "pol_decision": "approved", + }, ) ect_b = create(payload_b, key_b, CreateOptions(key_id=kid_b)) - print("Agent B created child ECT (jti=550e8400-...002, implement_module, par=[parent jti])") + print("Agent B created child ECT (jti=550e8400-...002, implement_module, pred=[predecessor jti])") # 4) Verify child ECT with DAG def resolver_b(kid): diff --git a/refimpl/python/ect/__init__.py b/refimpl/python/ect/__init__.py index 7ec7c25..7c25ac6 100644 --- a/refimpl/python/ect/__init__.py +++ b/refimpl/python/ect/__init__.py @@ -1,13 +1,10 @@ # WIMSE Execution Context Tokens (ECT) — Python reference implementation -# draft-nennemann-wimse-execution-context-00 +# draft-nennemann-wimse-execution-context-01 from ect.types import ( ECT_TYPE, - POL_DECISION_APPROVED, - POL_DECISION_REJECTED, - POL_DECISION_PENDING_HUMAN_REVIEW, + ECT_TYPE_LEGACY, Payload, - valid_pol_decision, ) from ect.create import create, generate_key, CreateOptions, default_create_options from ect.verify import ( @@ -30,11 +27,8 @@ from ect.jti_cache import JTICache, new_jti_cache __all__ = [ "ECT_TYPE", - "POL_DECISION_APPROVED", - "POL_DECISION_REJECTED", - "POL_DECISION_PENDING_HUMAN_REVIEW", + "ECT_TYPE_LEGACY", "Payload", - "valid_pol_decision", "create", "generate_key", "CreateOptions", diff --git a/refimpl/python/ect/create.py b/refimpl/python/ect/create.py index 46cd2c0..8af4bf1 100644 --- a/refimpl/python/ect/create.py +++ b/refimpl/python/ect/create.py @@ -10,9 +10,9 @@ from typing import Optional import jwt from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey -from ect.types import Payload, valid_pol_decision +from ect.types import ECT_TYPE, Payload from ect.validate import ( - DEFAULT_MAX_PAR_LENGTH, + DEFAULT_MAX_PRED_LENGTH, validate_ext, validate_hash_format, valid_uuid, @@ -25,7 +25,7 @@ class CreateOptions: iat_max_age_sec: int = 900 # 15 min default_expiry_sec: int = 600 # 10 min validate_uuids: bool = False - max_par_length: int = 0 # 0 = no limit; use DEFAULT_MAX_PAR_LENGTH for 100 + max_pred_length: int = 0 # 0 = no limit; use DEFAULT_MAX_PRED_LENGTH for 100 def default_create_options() -> CreateOptions: @@ -46,22 +46,14 @@ def _validate_payload(p: Payload, opts: CreateOptions) -> None: raise ValueError("ect: jti must be UUID format") if p.wid and not valid_uuid(p.wid): raise ValueError("ect: wid must be UUID format when set") - max_par = opts.max_par_length or 0 - if max_par > 0 and len(p.par) > max_par: - raise ValueError("ect: par exceeds max length") + max_pred = opts.max_pred_length or 0 + if max_pred > 0 and len(p.pred) > max_pred: + raise ValueError("ect: pred exceeds max length") if p.inp_hash: validate_hash_format(p.inp_hash) if p.out_hash: validate_hash_format(p.out_hash) validate_ext(p.ext) - # pol/pol_decision OPTIONAL; if either set, both must be present and valid - if p.pol or p.pol_decision: - if not p.pol or not p.pol_decision: - raise ValueError("ect: pol and pol_decision must both be present when either is set") - if not valid_pol_decision(p.pol_decision): - raise ValueError( - "ect: pol_decision must be approved, rejected, or pending_human_review" - ) # compensation in ext per spec if p.ext and p.ext.get("compensation_reason") and not p.ext.get("compensation_required"): raise ValueError("ect: ext.compensation_reason requires ext.compensation_required true") @@ -73,8 +65,7 @@ def create( opts: CreateOptions, ) -> str: """Build and sign an ECT. Payload must have required claims; iat/exp can be 0 for defaults. - create() may modify the payload in place (iat, exp, sub, par) when filling defaults; - pass a copy if the original must stay unchanged. + create() works on a deep copy so the caller's payload is not modified. """ if not opts.key_id: raise ValueError("ect: KeyID required") @@ -87,16 +78,14 @@ def create( payload.iat = now if payload.exp == 0: payload.exp = now + (opts.default_expiry_sec or 600) - if not payload.sub: - payload.sub = payload.iss - if payload.par is None: - payload.par = [] + if payload.pred is None: + payload.pred = [] _validate_payload(payload, opts) claims = payload.to_claims() headers = { - "typ": "wimse-exec+jwt", + "typ": ECT_TYPE, "alg": "ES256", "kid": opts.key_id, } diff --git a/refimpl/python/ect/dag.py b/refimpl/python/ect/dag.py index 5819625..b1ca617 100644 --- a/refimpl/python/ect/dag.py +++ b/refimpl/python/ect/dag.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: from ect.types import Payload -from ect.validate import DEFAULT_MAX_PAR_LENGTH +from ect.validate import DEFAULT_MAX_PRED_LENGTH DEFAULT_CLOCK_SKEW_TOLERANCE = 30 DEFAULT_MAX_ANCESTOR_LIMIT = 10000 @@ -31,11 +31,11 @@ class DAGConfig: self, clock_skew_tolerance: int = DEFAULT_CLOCK_SKEW_TOLERANCE, max_ancestor_limit: int = DEFAULT_MAX_ANCESTOR_LIMIT, - max_par_length: int = 0, + max_pred_length: int = 0, ): self.clock_skew_tolerance = clock_skew_tolerance or DEFAULT_CLOCK_SKEW_TOLERANCE self.max_ancestor_limit = max_ancestor_limit or DEFAULT_MAX_ANCESTOR_LIMIT - self.max_par_length = max_par_length or 0 + self.max_pred_length = max_pred_length or 0 def default_dag_config() -> DAGConfig: @@ -44,22 +44,22 @@ def default_dag_config() -> DAGConfig: def _has_cycle( target_tid: str, - parent_ids: list[str], + pred_ids: list[str], store: ECTStore, visited: set[str], max_depth: int, ) -> bool: if len(visited) >= max_depth: return True - for parent_id in parent_ids: - if parent_id == target_tid: + for pred_id in pred_ids: + if pred_id == target_tid: return True - if parent_id in visited: + if pred_id in visited: continue - visited.add(parent_id) - parent = store.get_by_tid(parent_id) - if parent is not None: - if _has_cycle(target_tid, parent.par, store, visited, max_depth): + visited.add(pred_id) + pred = store.get_by_tid(pred_id) + if pred is not None: + if _has_cycle(target_tid, pred.pred, store, visited, max_depth): return True return False @@ -69,29 +69,28 @@ def validate_dag( store: ECTStore, cfg: DAGConfig, ) -> None: - """Section 6.2: uniqueness (by jti), parent existence, temporal ordering, acyclicity, parent policy.""" - if cfg.max_par_length > 0 and len(payload.par) > cfg.max_par_length: - raise ValueError("ect: par exceeds max length") + """Section 6.2: uniqueness (by jti), predecessor existence, temporal ordering, acyclicity, predecessor policy.""" + if cfg.max_pred_length > 0 and len(payload.pred) > cfg.max_pred_length: + raise ValueError("ect: pred exceeds max length") if store.contains(payload.jti, payload.wid or ""): raise ValueError(f"ect: task ID (jti) already exists: {payload.jti}") - from ect.types import POL_DECISION_REJECTED, POL_DECISION_PENDING_HUMAN_REVIEW - for parent_id in payload.par: - parent = store.get_by_tid(parent_id) - if parent is None: - raise ValueError(f"ect: parent task not found: {parent_id}") - if parent.iat >= payload.iat + cfg.clock_skew_tolerance: - raise ValueError(f"ect: parent task not earlier than current: {parent_id}") + for pred_id in payload.pred: + pred = store.get_by_tid(pred_id) + if pred is None: + raise ValueError(f"ect: predecessor task not found: {pred_id}") + if pred.iat >= payload.iat + cfg.clock_skew_tolerance: + raise ValueError(f"ect: predecessor task not earlier than current: {pred_id}") visited: set[str] = set() - if _has_cycle(payload.jti, payload.par, store, visited, cfg.max_ancestor_limit): + if _has_cycle(payload.jti, payload.pred, store, visited, cfg.max_ancestor_limit): raise ValueError("ect: circular dependency or depth limit exceeded") - # Parent policy decision: only when parent has policy claims per spec - for parent_id in payload.par: - parent = store.get_by_tid(parent_id) - if parent and parent.has_policy_claims() and parent.pol_decision in (POL_DECISION_REJECTED, POL_DECISION_PENDING_HUMAN_REVIEW): + # Predecessor policy decision: only when predecessor has policy claims in ext per -01 + for pred_id in payload.pred: + pred = store.get_by_tid(pred_id) + if pred and pred.has_policy_claims() and pred.pol_decision() in ("rejected", "pending_human_review"): if not payload.compensation_required(): raise ValueError( - "ect: parent has non-approved pol_decision; current ECT must be compensation/remediation or have ext.compensation_required true" + "ect: predecessor has non-approved pol_decision; current ECT must be compensation/remediation or have ext.compensation_required true" ) diff --git a/refimpl/python/ect/ledger.py b/refimpl/python/ect/ledger.py index fef46fa..025408c 100644 --- a/refimpl/python/ect/ledger.py +++ b/refimpl/python/ect/ledger.py @@ -23,7 +23,7 @@ class LedgerEntry: task_id: str agent_id: str action: str - parents: list[str] + predecessors: list[str] ect_jws: str signature_verified: bool verification_timestamp: float @@ -70,7 +70,7 @@ class MemoryLedger(Ledger): task_id=payload.jti, agent_id=payload.iss, action=payload.exec_act, - parents=list(payload.par) if payload.par else [], + predecessors=list(payload.pred) if payload.pred else [], ect_jws=ect_jws, signature_verified=True, verification_timestamp=now, diff --git a/refimpl/python/ect/types.py b/refimpl/python/ect/types.py index da1f443..b70a9de 100644 --- a/refimpl/python/ect/types.py +++ b/refimpl/python/ect/types.py @@ -1,4 +1,4 @@ -"""ECT payload and claim types per draft Section 4.""" +"""ECT payload and claim types per draft-nennemann-wimse-ect-01 Section 4.""" from __future__ import annotations @@ -6,19 +6,9 @@ import json from dataclasses import dataclass, field from typing import Any -ECT_TYPE = "wimse-exec+jwt" - -POL_DECISION_APPROVED = "approved" -POL_DECISION_REJECTED = "rejected" -POL_DECISION_PENDING_HUMAN_REVIEW = "pending_human_review" - - -def valid_pol_decision(s: str) -> bool: - return s in ( - POL_DECISION_APPROVED, - POL_DECISION_REJECTED, - POL_DECISION_PENDING_HUMAN_REVIEW, - ) +# Preferred typ per -01; legacy accepted for backward compatibility. +ECT_TYPE = "exec+jwt" +ECT_TYPE_LEGACY = "wimse-exec+jwt" def _audience_serialize(aud: list[str]) -> str | list[str]: @@ -45,20 +35,15 @@ class Payload: exp: int jti: str exec_act: str - par: list[str] - pol: str = "" - pol_decision: str = "" - sub: str = "" + pred: list[str] # predecessor jti values (renamed from par in -01) wid: str = "" - pol_enforcer: str = "" - pol_timestamp: int = 0 inp_hash: str = "" out_hash: str = "" inp_classification: str = "" ext: dict[str, Any] = field(default_factory=dict) def to_claims(self) -> dict[str, Any]: - """Export as JWT claims. Compensation in ext per spec.""" + """Export as JWT claims. Policy and compensation in ext per -01 spec.""" out: dict[str, Any] = { "iss": self.iss, "aud": _audience_serialize(self.aud), @@ -66,20 +51,10 @@ class Payload: "exp": self.exp, "jti": self.jti, "exec_act": self.exec_act, - "par": self.par, + "pred": self.pred, } - if self.sub: - out["sub"] = self.sub if self.wid: out["wid"] = self.wid - if self.pol: - out["pol"] = self.pol - if self.pol_decision: - out["pol_decision"] = self.pol_decision - if self.pol_enforcer: - out["pol_enforcer"] = self.pol_enforcer - if self.pol_timestamp: - out["pol_timestamp"] = self.pol_timestamp if self.inp_hash: out["inp_hash"] = self.inp_hash if self.out_hash: @@ -87,13 +62,13 @@ class Payload: if self.inp_classification: out["inp_classification"] = self.inp_classification if self.ext: - out["ext"] = dict(self.ext) + out["ect_ext"] = dict(self.ext) return out @classmethod def from_claims(cls, claims: dict[str, Any]) -> Payload: - """Build Payload from JWT claims. Compensation read from ext per spec.""" - ext = claims.get("ext") or {} + """Build Payload from JWT claims. Policy claims read from ext per -01 spec.""" + ext = claims.get("ect_ext") or {} return cls( iss=claims["iss"], aud=_audience_deserialize(claims["aud"]), @@ -101,13 +76,8 @@ class Payload: exp=int(claims["exp"]), jti=claims["jti"], exec_act=claims["exec_act"], - par=claims.get("par") or [], - pol=claims.get("pol", ""), - pol_decision=claims.get("pol_decision", ""), - sub=claims.get("sub", ""), + pred=claims.get("pred") or [], wid=claims.get("wid", ""), - pol_enforcer=claims.get("pol_enforcer", ""), - pol_timestamp=int(claims.get("pol_timestamp") or 0), inp_hash=claims.get("inp_hash", ""), out_hash=claims.get("out_hash", ""), inp_classification=claims.get("inp_classification", ""), @@ -124,5 +94,13 @@ class Payload: return bool(self.ext.get("compensation_required")) def has_policy_claims(self) -> bool: - """True if both pol and pol_decision are present (optional pair per spec).""" - return bool(self.pol and self.pol_decision) + """True if both pol and pol_decision are present in ext (per -01, moved to extension).""" + if not self.ext: + return False + return bool(self.ext.get("pol")) and bool(self.ext.get("pol_decision")) + + def pol_decision(self) -> str: + """Return pol_decision from ext, or empty string.""" + if not self.ext: + return "" + return str(self.ext.get("pol_decision", "")) diff --git a/refimpl/python/ect/validate.py b/refimpl/python/ect/validate.py index 0412f23..e07fc71 100644 --- a/refimpl/python/ect/validate.py +++ b/refimpl/python/ect/validate.py @@ -9,7 +9,7 @@ from typing import Any EXT_MAX_SIZE = 4096 EXT_MAX_DEPTH = 5 -DEFAULT_MAX_PAR_LENGTH = 100 +DEFAULT_MAX_PRED_LENGTH = 100 _UUID_RE = re.compile( r"^[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}$" diff --git a/refimpl/python/ect/verify.py b/refimpl/python/ect/verify.py index 9ffa8ce..01be36d 100644 --- a/refimpl/python/ect/verify.py +++ b/refimpl/python/ect/verify.py @@ -10,7 +10,7 @@ from typing import Callable, Optional import jwt from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicKey -from ect.types import ECT_TYPE, Payload, valid_pol_decision +from ect.types import ECT_TYPE, ECT_TYPE_LEGACY, Payload from ect.dag import ECTStore, DAGConfig, validate_dag from ect.validate import validate_ext, validate_hash_format, valid_uuid @@ -37,7 +37,7 @@ class VerifyOptions: jti_seen: Optional[Callable[[str], bool]] = None wit_subject: str = "" validate_uuids: bool = False - max_par_length: int = 0 # 0 = no limit + max_pred_length: int = 0 # 0 = no limit on_verify_attempt: Optional[Callable[[str, Optional[Exception]], None]] = None # (jti, err) for observability @@ -83,8 +83,8 @@ def verify(compact: str, opts: VerifyOptions) -> ParsedECT: def _verify_impl(compact: str, opts: VerifyOptions, set_log_jti: Callable[[str], None]) -> ParsedECT: header = jwt.get_unverified_header(compact) typ = header.get("typ") or "" - # Constant-time comparison for typ - if not hmac.compare_digest(typ, ECT_TYPE): + # Constant-time comparison for typ; accept both preferred and legacy values + if not hmac.compare_digest(typ, ECT_TYPE) and not hmac.compare_digest(typ, ECT_TYPE_LEGACY): raise ValueError("ect: invalid typ parameter") alg = header.get("alg") if alg in ("none", "HS256", "HS384", "HS512"): @@ -114,8 +114,8 @@ def _verify_impl(compact: str, opts: VerifyOptions, set_log_jti: Callable[[str], set_log_jti(payload.jti) validate_ext(payload.ext) - if opts.max_par_length > 0 and len(payload.par) > opts.max_par_length: - raise ValueError("ect: par exceeds max length") + if opts.max_pred_length > 0 and len(payload.pred) > opts.max_pred_length: + raise ValueError("ect: pred exceeds max length") if opts.validate_uuids: if not valid_uuid(payload.jti): raise ValueError("ect: jti must be UUID format") @@ -139,17 +139,11 @@ def _verify_impl(compact: str, opts: VerifyOptions, set_log_jti: Callable[[str], if payload.iat > now + opts.iat_max_future_sec: raise ValueError("ect: iat in the future") - # Required claims per spec: jti, exec_act, par. par may be set to [] when missing (from_claims already uses []). + # Required claims per spec: jti, exec_act, pred. pred may be set to [] when missing (from_claims already uses []). if not payload.jti or not payload.exec_act: - raise ValueError("ect: missing required claims (jti, exec_act, par)") - if payload.par is None: - payload.par = [] - # If pol or pol_decision present, both must be present and valid - if payload.pol or payload.pol_decision: - if not payload.pol or not payload.pol_decision: - raise ValueError("ect: pol and pol_decision must both be present when either is set") - if not valid_pol_decision(payload.pol_decision): - raise ValueError("ect: invalid pol_decision value") + raise ValueError("ect: missing required claims (jti, exec_act, pred)") + if payload.pred is None: + payload.pred = [] if opts.store is not None and opts.dag is not None: validate_dag(payload, opts.store, opts.dag) diff --git a/refimpl/python/testdata/valid_root_ect_payload.json b/refimpl/python/testdata/valid_root_ect_payload.json index 8105b0e..46aa833 100644 --- a/refimpl/python/testdata/valid_root_ect_payload.json +++ b/refimpl/python/testdata/valid_root_ect_payload.json @@ -1 +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"} +{"iss":"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","pred":[],"ect_ext":{"pol":"clinical_reasoning_policy_v2","pol_decision":"approved"}} diff --git a/refimpl/python/tests/test_create.py b/refimpl/python/tests/test_create.py index f543f6f..b4470d0 100644 --- a/refimpl/python/tests/test_create.py +++ b/refimpl/python/tests/test_create.py @@ -13,7 +13,6 @@ from ect import ( CreateOptions, verify, VerifyOptions, - POL_DECISION_APPROVED, ) @@ -27,9 +26,7 @@ def test_create_roundtrip(): exp=now + 600, jti="e4f5a6b7-c8d9-0123-ef01-234567890abc", exec_act="review_spec", - par=[], - pol="spec_review_policy_v2", - pol_decision=POL_DECISION_APPROVED, + pred=[], ) compact = create(payload, key, CreateOptions(key_id="agent-a-key-1")) assert compact diff --git a/refimpl/python/tests/test_create_extra.py b/refimpl/python/tests/test_create_extra.py index 27e2965..0b72d8e 100644 --- a/refimpl/python/tests/test_create_extra.py +++ b/refimpl/python/tests/test_create_extra.py @@ -4,7 +4,7 @@ import time import pytest -from ect import Payload, create, generate_key, CreateOptions, default_create_options, POL_DECISION_APPROVED +from ect import Payload, create, generate_key, CreateOptions, default_create_options def test_default_create_options(): @@ -14,7 +14,7 @@ def test_default_create_options(): def test_create_errors(): key = generate_key() - p = Payload(iss="i", aud=["a"], iat=1, exp=2, jti="j", exec_act="e", par=[], pol="p", pol_decision=POL_DECISION_APPROVED) + p = Payload(iss="i", aud=["a"], iat=1, exp=2, jti="j", exec_act="e", pred=[]) with pytest.raises(ValueError, match="KeyID|required"): create(p, key, CreateOptions(key_id="")) with pytest.raises((ValueError, TypeError, AttributeError)): @@ -26,7 +26,7 @@ def test_create_optional_pol(): now = int(time.time()) p = Payload( iss="iss", aud=["a"], iat=now, exp=now + 3600, - jti="jti-nopol", exec_act="act", par=[], + jti="jti-nopol", exec_act="act", pred=[], ) compact = create(p, key, CreateOptions(key_id="kid")) assert compact @@ -34,7 +34,7 @@ def test_create_optional_pol(): def test_create_validation_errors(): key = generate_key() - base = dict(iss="i", aud=["a"], iat=1, exp=2, jti="j", exec_act="e", par=[]) + base = dict(iss="i", aud=["a"], iat=1, exp=2, jti="j", exec_act="e", pred=[]) with pytest.raises(ValueError, match="iss"): create(Payload(**{**base, "iss": ""}), key, CreateOptions(key_id="k")) with pytest.raises(ValueError, match="aud"): @@ -43,16 +43,12 @@ def test_create_validation_errors(): create(Payload(**{**base, "jti": ""}), key, CreateOptions(key_id="k")) with pytest.raises(ValueError, match="exec_act"): create(Payload(**{**base, "exec_act": ""}), key, CreateOptions(key_id="k")) - with pytest.raises(ValueError, match="pol and pol_decision"): - create(Payload(**{**base, "pol": "p", "pol_decision": ""}), key, CreateOptions(key_id="k")) - with pytest.raises(ValueError, match="pol_decision"): - create(Payload(**{**base, "pol": "p", "pol_decision": "bad"}), key, CreateOptions(key_id="k")) def test_create_ext_compensation_reason_requires_required(): key = generate_key() p = Payload( - iss="i", aud=["a"], iat=1, exp=2, jti="j", exec_act="e", par=[], + iss="i", aud=["a"], iat=1, exp=2, jti="j", exec_act="e", pred=[], ext={"compensation_reason": "rollback", "compensation_required": False}, ) with pytest.raises(ValueError, match="compensation_required"): @@ -61,7 +57,7 @@ def test_create_ext_compensation_reason_requires_required(): def test_create_zero_expiry_uses_default(): key = generate_key() - p = Payload(iss="i", aud=["a"], iat=0, exp=0, jti="j", exec_act="e", par=[]) + p = Payload(iss="i", aud=["a"], iat=0, exp=0, jti="j", exec_act="e", pred=[]) compact = create(p, key, CreateOptions(key_id="k", default_expiry_sec=300)) assert compact # create() works on a copy; decode the token to verify defaults were applied @@ -73,17 +69,17 @@ def test_create_zero_expiry_uses_default(): def test_create_validate_uuids_rejects_non_uuid_jti(): key = generate_key() now = int(time.time()) - p = Payload(iss="i", aud=["a"], iat=now, exp=now + 3600, jti="not-a-uuid", exec_act="e", par=[]) + p = Payload(iss="i", aud=["a"], iat=now, exp=now + 3600, jti="not-a-uuid", exec_act="e", pred=[]) with pytest.raises(ValueError, match="jti must be UUID"): create(p, key, CreateOptions(key_id="k", validate_uuids=True)) -def test_create_max_par_length(): +def test_create_max_pred_length(): key = generate_key() now = int(time.time()) - p = Payload(iss="i", aud=["a"], iat=now, exp=now + 3600, jti="550e8400-e29b-41d4-a716-446655440000", exec_act="e", par=["p1", "p2"]) - with pytest.raises(ValueError, match="par exceeds max length"): - create(p, key, CreateOptions(key_id="k", max_par_length=1)) + p = Payload(iss="i", aud=["a"], iat=now, exp=now + 3600, jti="550e8400-e29b-41d4-a716-446655440000", exec_act="e", pred=["p1", "p2"]) + with pytest.raises(ValueError, match="pred exceeds max length"): + create(p, key, CreateOptions(key_id="k", max_pred_length=1)) def test_create_ext_size_rejected(): @@ -91,7 +87,7 @@ def test_create_ext_size_rejected(): key = generate_key() now = int(time.time()) p = Payload( - iss="i", aud=["a"], iat=now, exp=now + 3600, jti="550e8400-e29b-41d4-a716-446655440000", exec_act="e", par=[], + iss="i", aud=["a"], iat=now, exp=now + 3600, jti="550e8400-e29b-41d4-a716-446655440000", exec_act="e", pred=[], ext={"x": "y" * (EXT_MAX_SIZE - 5)}, ) with pytest.raises(ValueError, match="ext exceeds max size"): diff --git a/refimpl/python/tests/test_dag.py b/refimpl/python/tests/test_dag.py index 052ea0a..3c228a0 100644 --- a/refimpl/python/tests/test_dag.py +++ b/refimpl/python/tests/test_dag.py @@ -4,7 +4,7 @@ import time import pytest -from ect import Payload, MemoryLedger, validate_dag, default_dag_config, POL_DECISION_APPROVED +from ect import Payload, MemoryLedger, validate_dag, default_dag_config def test_validate_dag_root(): @@ -16,9 +16,7 @@ def test_validate_dag_root(): exp=0, jti="jti-001", exec_act="", - par=[], - pol="", - pol_decision=POL_DECISION_APPROVED, + pred=[], wid="wf-1", ) validate_dag(payload, store, default_dag_config()) @@ -33,9 +31,7 @@ def test_validate_dag_duplicate_jti(): exp=0, jti="jti-001", exec_act="a", - par=[], - pol="p", - pol_decision=POL_DECISION_APPROVED, + pred=[], wid="wf-1", ) store.append("dummy-jws", p) @@ -46,16 +42,14 @@ def test_validate_dag_duplicate_jti(): exp=0, jti="jti-001", exec_act="", - par=[], - pol="", - pol_decision=POL_DECISION_APPROVED, + pred=[], wid="wf-1", ) with pytest.raises(ValueError, match="task ID.*already exists"): validate_dag(payload, store, default_dag_config()) -def test_validate_dag_parent_exists(): +def test_validate_dag_pred_exists(): store = MemoryLedger() now = int(time.time()) p = Payload( @@ -65,9 +59,7 @@ def test_validate_dag_parent_exists(): exp=now + 600, jti="jti-001", exec_act="a", - par=[], - pol="p", - pol_decision=POL_DECISION_APPROVED, + pred=[], wid="wf-1", ) store.append("jws1", p) @@ -78,15 +70,13 @@ def test_validate_dag_parent_exists(): exp=now + 600, jti="jti-002", exec_act="b", - par=["jti-001"], - pol="p", - pol_decision=POL_DECISION_APPROVED, + pred=["jti-001"], wid="wf-1", ) validate_dag(payload, store, default_dag_config()) -def test_validate_dag_parent_not_found(): +def test_validate_dag_pred_not_found(): store = MemoryLedger() now = int(time.time()) payload = Payload( @@ -96,26 +86,24 @@ def test_validate_dag_parent_not_found(): exp=now + 600, jti="jti-002", exec_act="", - par=["jti-missing"], - pol="", - pol_decision=POL_DECISION_APPROVED, + pred=["jti-missing"], ) - with pytest.raises(ValueError, match="parent task not found"): + with pytest.raises(ValueError, match="predecessor task not found"): validate_dag(payload, store, default_dag_config()) -def test_validate_dag_parent_policy_rejected_requires_compensation(): - from ect import POL_DECISION_REJECTED +def test_validate_dag_pred_policy_rejected_requires_compensation(): store = MemoryLedger() now = int(time.time()) p = Payload( iss="x", aud=["y"], iat=now - 60, exp=now + 600, - jti="jti-rej", exec_act="a", par=[], pol="p", pol_decision=POL_DECISION_REJECTED, wid="wf-1", + jti="jti-rej", exec_act="a", pred=[], wid="wf-1", + ext={"pol": "p", "pol_decision": "rejected"}, ) store.append("jws1", p) payload = Payload( iss="", aud=[], iat=now, exp=now + 600, - jti="jti-child", exec_act="b", par=["jti-rej"], pol="p", pol_decision=POL_DECISION_APPROVED, wid="wf-1", + jti="jti-child", exec_act="b", pred=["jti-rej"], wid="wf-1", ) with pytest.raises(ValueError, match="compensation"): validate_dag(payload, store, default_dag_config()) diff --git a/refimpl/python/tests/test_ledger_extra.py b/refimpl/python/tests/test_ledger_extra.py index db02d4b..b5fcfea 100644 --- a/refimpl/python/tests/test_ledger_extra.py +++ b/refimpl/python/tests/test_ledger_extra.py @@ -4,12 +4,12 @@ import time import pytest -from ect import Payload, MemoryLedger, ErrTaskIDExists, POL_DECISION_APPROVED +from ect import Payload, MemoryLedger, ErrTaskIDExists def test_ledger_append_and_get(): m = MemoryLedger() - p = Payload(iss="i", aud=["a"], iat=1, exp=2, jti="j1", exec_act="act", par=[]) + p = Payload(iss="i", aud=["a"], iat=1, exp=2, jti="j1", exec_act="act", pred=[]) seq = m.append("jws1", p) assert seq == 1 assert m.get_by_tid("j1").jti == "j1" @@ -17,7 +17,7 @@ def test_ledger_append_and_get(): def test_ledger_err_task_id_exists(): m = MemoryLedger() - p = Payload(iss="i", aud=["a"], iat=1, exp=2, jti="j-dup", exec_act="e", par=[]) + p = Payload(iss="i", aud=["a"], iat=1, exp=2, jti="j-dup", exec_act="e", pred=[]) m.append("jws1", p) with pytest.raises(ErrTaskIDExists): m.append("jws2", p) @@ -25,7 +25,7 @@ def test_ledger_err_task_id_exists(): def test_ledger_contains_wid(): m = MemoryLedger() - p = Payload(iss="i", aud=["a"], iat=1, exp=2, jti="j1", exec_act="e", par=[], wid="wf1") + p = Payload(iss="i", aud=["a"], iat=1, exp=2, jti="j1", exec_act="e", pred=[], wid="wf1") m.append("jws", p) assert m.contains("j1", "") is True assert m.contains("j1", "wf1") is True diff --git a/refimpl/python/tests/test_types_extra.py b/refimpl/python/tests/test_types_extra.py index 9d2ca2a..accbc45 100644 --- a/refimpl/python/tests/test_types_extra.py +++ b/refimpl/python/tests/test_types_extra.py @@ -2,62 +2,63 @@ import pytest -from ect import Payload, POL_DECISION_APPROVED -from ect.types import valid_pol_decision - - -def test_valid_pol_decision(): - assert valid_pol_decision("approved") is True - assert valid_pol_decision("rejected") is True - assert valid_pol_decision("pending_human_review") is True - assert valid_pol_decision("invalid") is False +from ect import Payload def test_payload_contains_audience(): - p = Payload(iss="", aud=["a", "b"], iat=0, exp=0, jti="", exec_act="", par=[]) + p = Payload(iss="", aud=["a", "b"], iat=0, exp=0, jti="", exec_act="", pred=[]) assert p.contains_audience("a") is True assert p.contains_audience("c") is False def test_payload_compensation_required(): - p = Payload(iss="", aud=[], iat=0, exp=0, jti="", exec_act="", par=[]) + p = Payload(iss="", aud=[], iat=0, exp=0, jti="", exec_act="", pred=[]) assert p.compensation_required() is False p.ext = {"compensation_required": True} assert p.compensation_required() is True def test_payload_has_policy_claims(): - p = Payload(iss="", aud=[], iat=0, exp=0, jti="", exec_act="", par=[], pol="p", pol_decision=POL_DECISION_APPROVED) + p = Payload(iss="", aud=[], iat=0, exp=0, jti="", exec_act="", pred=[], + ext={"pol": "p", "pol_decision": "approved"}) assert p.has_policy_claims() is True - p.pol = "" + p.ext = {"pol_decision": "approved"} + assert p.has_policy_claims() is False + p.ext = None assert p.has_policy_claims() is False +def test_payload_pol_decision(): + p = Payload(iss="", aud=[], iat=0, exp=0, jti="", exec_act="", pred=[], + ext={"pol_decision": "rejected"}) + assert p.pol_decision() == "rejected" + p.ext = None + assert p.pol_decision() == "" + + def test_payload_to_claims_optional(): - p = Payload(iss="i", aud=["a"], iat=1, exp=2, jti="j", exec_act="e", par=[], wid="wf") + p = Payload(iss="i", aud=["a"], iat=1, exp=2, jti="j", exec_act="e", pred=[], wid="wf") claims = p.to_claims() assert claims["wid"] == "wf" - assert "ext" not in claims or not claims.get("ext") + assert "ect_ext" not in claims or not claims.get("ect_ext") def test_payload_from_claims_aud_string(): - claims = {"iss": "i", "aud": "single", "iat": 1, "exp": 2, "jti": "j", "exec_act": "e", "par": []} + claims = {"iss": "i", "aud": "single", "iat": 1, "exp": 2, "jti": "j", "exec_act": "e", "pred": []} p = Payload.from_claims(claims) assert p.aud == ["single"] def test_payload_to_claims_all_optional(): p = Payload( - iss="i", aud=["a"], iat=1, exp=2, jti="j", exec_act="e", par=[], - sub="s", wid="w", pol="p", pol_decision="approved", pol_enforcer="e", - pol_timestamp=1, inp_hash="h", out_hash="o", inp_classification="c", + iss="i", aud=["a"], iat=1, exp=2, jti="j", exec_act="e", pred=[], + wid="w", inp_hash="h", out_hash="o", inp_classification="c", + ext={"pol": "p", "pol_decision": "approved"}, ) claims = p.to_claims() - assert claims["sub"] == "s" assert claims["wid"] == "w" - assert claims["pol"] == "p" - assert claims["pol_enforcer"] == "e" - assert claims["pol_timestamp"] == 1 assert claims["inp_hash"] == "h" assert claims["out_hash"] == "o" assert claims["inp_classification"] == "c" + assert claims["ect_ext"]["pol"] == "p" + assert claims["ect_ext"]["pol_decision"] == "approved" diff --git a/refimpl/python/tests/test_verify.py b/refimpl/python/tests/test_verify.py index 972f4fd..1cb3c83 100644 --- a/refimpl/python/tests/test_verify.py +++ b/refimpl/python/tests/test_verify.py @@ -13,7 +13,6 @@ from ect import ( verify, VerifyOptions, default_verify_options, - POL_DECISION_APPROVED, ) @@ -22,7 +21,7 @@ def test_parse(): now = int(time.time()) p = Payload( iss="iss", aud=["a"], iat=now, exp=now + 3600, - jti="jti-parse", exec_act="act", par=[], pol="p", pol_decision=POL_DECISION_APPROVED, + jti="jti-parse", exec_act="act", pred=[], ) compact = create(p, key, CreateOptions(key_id="kid")) parsed = parse(compact) @@ -41,7 +40,7 @@ def test_verify_expired(): now = int(time.time()) p = Payload( iss="iss", aud=["v"], iat=now - 3600, exp=now - 60, - jti="jti-exp", exec_act="act", par=[], pol="p", pol_decision=POL_DECISION_APPROVED, + jti="jti-exp", exec_act="act", pred=[], ) compact = create(p, key, CreateOptions(key_id="kid")) resolver = lambda kid: key.public_key() if kid == "kid" else None @@ -54,7 +53,7 @@ def test_verify_replay(): now = int(time.time()) p = Payload( iss="iss", aud=["v"], iat=now, exp=now + 3600, - jti="jti-replay", exec_act="act", par=[], pol="p", pol_decision=POL_DECISION_APPROVED, + jti="jti-replay", exec_act="act", pred=[], ) compact = create(p, key, CreateOptions(key_id="kid")) resolver = lambda kid: key.public_key() if kid == "kid" else None @@ -76,7 +75,7 @@ def test_verify_audience_mismatch(): now = int(time.time()) p = Payload( iss="iss", aud=["other"], iat=now, exp=now + 3600, - jti="jti-a", exec_act="act", par=[], pol="p", pol_decision=POL_DECISION_APPROVED, + jti="jti-a", exec_act="act", pred=[], ) compact = create(p, key, CreateOptions(key_id="kid")) resolver = lambda kid: key.public_key() if kid == "kid" else None @@ -89,7 +88,7 @@ def test_verify_wit_subject_mismatch(): now = int(time.time()) p = Payload( iss="wrong-iss", aud=["v"], iat=now, exp=now + 3600, - jti="jti-w", exec_act="act", par=[], pol="p", pol_decision=POL_DECISION_APPROVED, + jti="jti-w", exec_act="act", pred=[], ) compact = create(p, key, CreateOptions(key_id="kid")) resolver = lambda kid: key.public_key() if kid == "kid" else None @@ -104,7 +103,7 @@ def test_verify_iat_too_old(): now = int(time.time()) p = Payload( iss="iss", aud=["v"], iat=now - 2000, exp=now + 3600, - jti="jti-old", exec_act="act", par=[], pol="p", pol_decision=POL_DECISION_APPROVED, + jti="jti-old", exec_act="act", pred=[], ) compact = create(p, key, CreateOptions(key_id="kid")) resolver = lambda kid: key.public_key() if kid == "kid" else None @@ -119,7 +118,7 @@ def test_verify_unknown_key(): now = int(time.time()) p = Payload( iss="iss", aud=["v"], iat=now, exp=now + 3600, - jti="jti-k", exec_act="act", par=[], pol="p", pol_decision=POL_DECISION_APPROVED, + jti="jti-k", exec_act="act", pred=[], ) compact = create(p, key, CreateOptions(key_id="kid")) resolver = lambda kid: None # unknown key @@ -132,7 +131,7 @@ def test_verify_resolve_key_required(): now = int(time.time()) p = Payload( iss="iss", aud=["v"], iat=now, exp=now + 3600, - jti="jti-r", exec_act="act", par=[], pol="p", pol_decision=POL_DECISION_APPROVED, + jti="jti-r", exec_act="act", pred=[], ) compact = create(p, key, CreateOptions(key_id="kid")) with pytest.raises(ValueError, match="ResolveKey"): @@ -146,7 +145,7 @@ def test_verify_with_dag(): now = int(time.time()) root = Payload( iss="iss", aud=["v"], iat=now, exp=now + 3600, - jti="jti-root", exec_act="act", par=[], pol="p", pol_decision=POL_DECISION_APPROVED, + jti="jti-root", exec_act="act", pred=[], ) compact_root = create(root, key, CreateOptions(key_id="kid")) resolver = lambda kid: key.public_key() if kid == "kid" else None @@ -155,7 +154,7 @@ def test_verify_with_dag(): ledger.append(compact_root, parsed.payload) child = Payload( iss="iss", aud=["v"], iat=now + 1, exp=now + 3600, - jti="jti-child", exec_act="act2", par=["jti-root"], pol="p", pol_decision=POL_DECISION_APPROVED, + jti="jti-child", exec_act="act2", pred=["jti-root"], ) compact_child = create(child, key, CreateOptions(key_id="kid")) parsed2 = verify(compact_child, opts) @@ -166,7 +165,7 @@ def test_on_verify_attempt_callback(): """Observability: on_verify_attempt is called with jti and error (or None).""" key = generate_key() now = int(time.time()) - p = Payload(iss="i", aud=["v"], iat=now, exp=now + 3600, jti="jti-obs", exec_act="a", par=[]) + p = Payload(iss="i", aud=["v"], iat=now, exp=now + 3600, jti="jti-obs", exec_act="a", pred=[]) compact = create(p, key, CreateOptions(key_id="kid")) resolver = lambda k: key.public_key() if k == "kid" else None seen = [] @@ -183,7 +182,7 @@ def test_on_verify_attempt_callback(): def test_on_verify_attempt_called_on_failure(): key = generate_key() now = int(time.time()) - p = Payload(iss="i", aud=["v"], iat=now, exp=now - 1, jti="jti-fail", exec_act="a", par=[]) + p = Payload(iss="i", aud=["v"], iat=now, exp=now - 1, jti="jti-fail", exec_act="a", pred=[]) compact = create(p, key, CreateOptions(key_id="kid")) resolver = lambda k: key.public_key() if k == "kid" else None seen = [] @@ -193,5 +192,3 @@ def test_on_verify_attempt_called_on_failure(): assert len(seen) == 1 assert seen[0][0] == "jti-fail" assert seen[0][1] is not None - -