Compare commits
2 Commits
ba044f6626
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ba38569319 | |||
| 884d2dc836 |
@@ -50,7 +50,7 @@ Suggestions that could make the implementations more robust, spec-strict, or pro
|
|||||||
## 5. **Nice-to-have** ✅
|
## 5. **Nice-to-have** ✅
|
||||||
|
|
||||||
- **inp_hash / out_hash format**
|
- **inp_hash / out_hash format**
|
||||||
**Done.** Optional check in create and verify: `algorithm:base64url` with algorithm in allowlist (sha-256, sha-384, sha-512). Helpers: `ValidateHashFormat` / `validate_hash_format`.
|
**Done.** Optional check in create and verify: plain base64url without algorithm prefix, per -01 spec and RFC 9449. Helpers: `ValidateHashFormat` / `validate_hash_format`.
|
||||||
|
|
||||||
- **Constant-time comparison**
|
- **Constant-time comparison**
|
||||||
**Done.** **Go:** `crypto/subtle.ConstantTimeCompare` for `typ` in verify. **Python:** `hmac.compare_digest` for `typ`.
|
**Done.** **Go:** `crypto/subtle.ConstantTimeCompare` for `typ` in verify. **Python:** `hmac.compare_digest` for `typ`.
|
||||||
@@ -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.
|
- **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`**: Policy claims were removed from the core spec. Deployments should use `ect_ext` for domain-specific claims like policy decisions.
|
- **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`**: The `sub` claim is not part of the ECT specification. Remove from types and examples.
|
- **Remove `sub`**: ✅ **Done.** Removed from Payload struct (Go) and dataclass (Python). Create no longer defaults sub=iss.
|
||||||
- **Update `typ` default**: Prefer `exec+jwt` over `wimse-exec+jwt`. Both must be accepted for backward compatibility.
|
- **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 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.
|
- **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**: ✅ **Done.** Both Go and Python validate plain base64url without algorithm prefix, consistent with -01 spec and RFC 9449.
|
||||||
- **Update hash format**: The -01 draft specifies SHA-256 base64url without algorithm prefix (no `sha-256:` prefix), consistent with RFC 9449.
|
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
# WIMSE Execution Context Tokens — Reference Implementations
|
# WIMSE Execution Context Tokens — Reference Implementations
|
||||||
|
|
||||||
> **Note**: These reference implementations were built against **draft-nennemann-wimse-ect-00**.
|
> These reference implementations are aligned with **draft-nennemann-wimse-ect-01**.
|
||||||
> The current draft (**-01**) introduced several claim name changes and structural updates:
|
|
||||||
>
|
>
|
||||||
> | -00 (refimpl) | -01 (current draft) | Notes |
|
> The following claim name changes from -00 have been applied:
|
||||||
> |---------------|---------------------|-------|
|
>
|
||||||
|
> | -00 (previous) | -01 (current) | Notes |
|
||||||
|
> |----------------|---------------|-------|
|
||||||
> | `par` | `pred` | Predecessor task IDs |
|
> | `par` | `pred` | Predecessor task IDs |
|
||||||
> | `pol`, `pol_decision` | removed (use `ect_ext`) | Policy claims moved to extension object |
|
> | `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 |
|
> | `typ: wimse-exec+jwt` | `typ: exec+jwt` (preferred) | Both accepted for backward compat |
|
||||||
>
|
> | `MaxParLength` | `MaxPredLength` | Renamed to match `pred` claim |
|
||||||
> The refimpl update to -01 is tracked in IMPROVEMENTS.md.
|
|
||||||
|
|
||||||
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.
|
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.
|
- **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.
|
- **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).
|
- **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.
|
- **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.
|
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
|
## Specification
|
||||||
|
|
||||||
- **Current draft**: `draft-nennemann-wimse-ect-01`
|
- **Current draft**: `draft-nennemann-wimse-ect-01`
|
||||||
- **Refimpl implements**: `-00` claim names (see migration note above)
|
- **Refimpl implements**: `-01` claim names
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# WIMSE ECT — Go Reference Implementation
|
# 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
|
## Layout
|
||||||
|
|
||||||
@@ -43,9 +43,11 @@ payload := &ect.Payload{
|
|||||||
Exp: time.Now().Add(10*time.Minute).Unix(),
|
Exp: time.Now().Add(10*time.Minute).Unix(),
|
||||||
Jti: "550e8400-e29b-41d4-a716-446655440000",
|
Jti: "550e8400-e29b-41d4-a716-446655440000",
|
||||||
ExecAct: "review_spec",
|
ExecAct: "review_spec",
|
||||||
Par: []string{},
|
Pred: []string{},
|
||||||
Pol: "policy_v1",
|
Ext: map[string]interface{}{
|
||||||
PolDecision: ect.PolDecisionApproved,
|
"pol": "policy_v1",
|
||||||
|
"pol_decision": "approved",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
compact, err := ect.Create(payload, key, cfg.CreateOptions("agent-a-key"))
|
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.
|
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)
|
## Production configuration (environment)
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| 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)
|
### 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
|
## Dependencies
|
||||||
|
|
||||||
|
|||||||
@@ -34,9 +34,11 @@ func main() {
|
|||||||
Jti: "550e8400-e29b-41d4-a716-446655440001",
|
Jti: "550e8400-e29b-41d4-a716-446655440001",
|
||||||
Wid: "wf-demo-001",
|
Wid: "wf-demo-001",
|
||||||
ExecAct: "review_requirements_spec",
|
ExecAct: "review_requirements_spec",
|
||||||
Par: []string{},
|
Pred: []string{},
|
||||||
Pol: "spec_review_policy_v2",
|
Ext: map[string]interface{}{
|
||||||
PolDecision: ect.PolDecisionApproved,
|
"pol": "spec_review_policy_v2",
|
||||||
|
"pol_decision": "approved",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
ectA, err := ect.Create(payloadA, keyA, ect.CreateOptions{KeyID: kidA})
|
ectA, err := ect.Create(payloadA, keyA, ect.CreateOptions{KeyID: kidA})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -69,7 +71,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
fmt.Println("Agent B verified root ECT and appended to ledger")
|
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()
|
keyB, _ := ect.GenerateKey()
|
||||||
kidB := "agent-b-key"
|
kidB := "agent-b-key"
|
||||||
payloadB := &ect.Payload{
|
payloadB := &ect.Payload{
|
||||||
@@ -80,15 +82,17 @@ func main() {
|
|||||||
Jti: "550e8400-e29b-41d4-a716-446655440002",
|
Jti: "550e8400-e29b-41d4-a716-446655440002",
|
||||||
Wid: "wf-demo-001",
|
Wid: "wf-demo-001",
|
||||||
ExecAct: "implement_module",
|
ExecAct: "implement_module",
|
||||||
Par: []string{"550e8400-e29b-41d4-a716-446655440001"},
|
Pred: []string{"550e8400-e29b-41d4-a716-446655440001"},
|
||||||
Pol: "coding_standards_v3",
|
Ext: map[string]interface{}{
|
||||||
PolDecision: ect.PolDecisionApproved,
|
"pol": "coding_standards_v3",
|
||||||
|
"pol_decision": "approved",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
ectB, err := ect.Create(payloadB, keyB, ect.CreateOptions{KeyID: kidB})
|
ectB, err := ect.Create(payloadB, keyB, ect.CreateOptions{KeyID: kidB})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
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)
|
// 4) Verify child ECT with DAG (ledger has task-001)
|
||||||
resolverB := ect.KeyResolver(func(kid string) (*ecdsa.PublicKey, error) {
|
resolverB := ect.KeyResolver(func(kid string) (*ecdsa.PublicKey, error) {
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ type CreateOptions struct {
|
|||||||
DefaultExpiry time.Duration
|
DefaultExpiry time.Duration
|
||||||
// ValidateUUIDs when true requires jti and wid (if set) to be UUID format (RFC 9562).
|
// ValidateUUIDs when true requires jti and wid (if set) to be UUID format (RFC 9562).
|
||||||
ValidateUUIDs bool
|
ValidateUUIDs bool
|
||||||
// MaxParLength is the max number of parent references (0 = no limit; recommended 100).
|
// MaxPredLength is the max number of predecessor references (0 = no limit; recommended 100).
|
||||||
MaxParLength int
|
MaxPredLength int
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultCreateOptions returns recommended defaults.
|
// DefaultCreateOptions returns recommended defaults.
|
||||||
@@ -53,11 +53,8 @@ func Create(payload *Payload, privateKey *ecdsa.PrivateKey, opts CreateOptions)
|
|||||||
}
|
}
|
||||||
payload.Exp = now.Add(opts.DefaultExpiry).Unix()
|
payload.Exp = now.Add(opts.DefaultExpiry).Unix()
|
||||||
}
|
}
|
||||||
if payload.Sub == "" {
|
if payload.Pred == nil {
|
||||||
payload.Sub = payload.Iss
|
payload.Pred = []string{}
|
||||||
}
|
|
||||||
if payload.Par == nil {
|
|
||||||
payload.Par = []string{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validatePayloadForCreate(payload, opts); err != nil {
|
if err := validatePayloadForCreate(payload, opts); err != nil {
|
||||||
@@ -110,8 +107,8 @@ func validatePayloadForCreate(p *Payload, opts CreateOptions) error {
|
|||||||
return ErrInvalidWID
|
return ErrInvalidWID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if opts.MaxParLength > 0 && len(p.Par) > opts.MaxParLength {
|
if opts.MaxPredLength > 0 && len(p.Pred) > opts.MaxPredLength {
|
||||||
return ErrParLength
|
return ErrPredLength
|
||||||
}
|
}
|
||||||
if p.InpHash != "" {
|
if p.InpHash != "" {
|
||||||
if err := ValidateHashFormat(p.InpHash); err != nil {
|
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 {
|
if err := ValidateExt(p.Ext); err != nil {
|
||||||
return err
|
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
|
// compensation_* live in ext per spec
|
||||||
if p.Ext != nil {
|
if p.Ext != nil {
|
||||||
if _, hasReason := p.Ext["compensation_reason"]; hasReason {
|
if _, hasReason := p.Ext["compensation_reason"]; hasReason {
|
||||||
|
|||||||
@@ -21,9 +21,7 @@ func TestCreateRoundtrip(t *testing.T) {
|
|||||||
Exp: now.Add(10 * time.Minute).Unix(),
|
Exp: now.Add(10 * time.Minute).Unix(),
|
||||||
Jti: "e4f5a6b7-c8d9-0123-ef01-234567890abc",
|
Jti: "e4f5a6b7-c8d9-0123-ef01-234567890abc",
|
||||||
ExecAct: "review_spec",
|
ExecAct: "review_spec",
|
||||||
Par: []string{},
|
Pred: []string{},
|
||||||
Pol: "spec_review_policy_v2",
|
|
||||||
PolDecision: PolDecisionApproved,
|
|
||||||
}
|
}
|
||||||
compact, err := Create(payload, key, CreateOptions{KeyID: "agent-a-key-1"})
|
compact, err := Create(payload, key, CreateOptions{KeyID: "agent-a-key-1"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -68,7 +66,7 @@ func TestDefaultCreateOptions(t *testing.T) {
|
|||||||
|
|
||||||
func TestCreate_Errors(t *testing.T) {
|
func TestCreate_Errors(t *testing.T) {
|
||||||
key, _ := GenerateKey()
|
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 {
|
if _, err := Create(nil, key, CreateOptions{KeyID: "k"}); err == nil {
|
||||||
t.Error("expected error for nil payload")
|
t.Error("expected error for nil payload")
|
||||||
}
|
}
|
||||||
@@ -85,7 +83,7 @@ func TestCreate_OptionalPol(t *testing.T) {
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
payload := &Payload{
|
payload := &Payload{
|
||||||
Iss: "iss", Aud: []string{"aud"}, Iat: now.Unix(), Exp: now.Add(time.Hour).Unix(),
|
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"})
|
compact, err := Create(payload, key, CreateOptions{KeyID: "kid"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -100,7 +98,7 @@ func TestCreate_ZeroExpiryUsesDefault(t *testing.T) {
|
|||||||
key, _ := GenerateKey()
|
key, _ := GenerateKey()
|
||||||
payload := &Payload{
|
payload := &Payload{
|
||||||
Iss: "i", Aud: []string{"a"}, Iat: 0, Exp: 0,
|
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})
|
_, err := Create(payload, key, CreateOptions{KeyID: "kid", DefaultExpiry: 5 * time.Minute})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -115,7 +113,7 @@ func TestCreate_ExtCompensationReasonRequiresRequired(t *testing.T) {
|
|||||||
key, _ := GenerateKey()
|
key, _ := GenerateKey()
|
||||||
payload := &Payload{
|
payload := &Payload{
|
||||||
Iss: "i", Aud: []string{"a"}, Iat: 1, Exp: 2,
|
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},
|
Ext: map[string]interface{}{"compensation_reason": "rollback", "compensation_required": false},
|
||||||
}
|
}
|
||||||
_, err := Create(payload, key, CreateOptions{KeyID: "k"})
|
_, err := Create(payload, key, CreateOptions{KeyID: "k"})
|
||||||
@@ -130,12 +128,10 @@ func TestCreate_ValidationErrors(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
p *Payload
|
p *Payload
|
||||||
}{
|
}{
|
||||||
{"missing iss", &Payload{Iss: "", Aud: []string{"a"}, Jti: "j", ExecAct: "e", Par: []string{}, 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", Par: []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", Par: []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: "", Par: []string{}, Iat: 1, Exp: 2}},
|
{"missing exec_act", &Payload{Iss: "i", Aud: []string{"a"}, Jti: "j", ExecAct: "", Pred: []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 {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ type ECTStore interface {
|
|||||||
type DAGConfig struct {
|
type DAGConfig struct {
|
||||||
ClockSkewTolerance int // seconds; recommended 30
|
ClockSkewTolerance int // seconds; recommended 30
|
||||||
MaxAncestorLimit int // recommended 10000
|
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.
|
// DefaultDAGConfig returns recommended defaults.
|
||||||
@@ -32,7 +32,7 @@ func DefaultDAGConfig() DAGConfig {
|
|||||||
return DAGConfig{
|
return DAGConfig{
|
||||||
ClockSkewTolerance: DefaultClockSkewTolerance,
|
ClockSkewTolerance: DefaultClockSkewTolerance,
|
||||||
MaxAncestorLimit: DefaultMaxAncestorLimit,
|
MaxAncestorLimit: DefaultMaxAncestorLimit,
|
||||||
MaxParLength: DefaultMaxParLength,
|
MaxPredLength: DefaultMaxPredLength,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,8 +48,8 @@ func ValidateDAG(ect *Payload, store ECTStore, cfg DAGConfig) error {
|
|||||||
if cfg.MaxAncestorLimit <= 0 {
|
if cfg.MaxAncestorLimit <= 0 {
|
||||||
cfg.MaxAncestorLimit = DefaultMaxAncestorLimit
|
cfg.MaxAncestorLimit = DefaultMaxAncestorLimit
|
||||||
}
|
}
|
||||||
if cfg.MaxParLength > 0 && len(ect.Par) > cfg.MaxParLength {
|
if cfg.MaxPredLength > 0 && len(ect.Pred) > cfg.MaxPredLength {
|
||||||
return ErrParLength
|
return ErrPredLength
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Task ID Uniqueness (task id = jti per spec)
|
// 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)
|
return fmt.Errorf("ect: task ID (jti) already exists: %s", ect.Jti)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Parent Existence and 3. Temporal Ordering
|
// 2. Predecessor Existence and 3. Temporal Ordering
|
||||||
for _, parentID := range ect.Par {
|
for _, predID := range ect.Pred {
|
||||||
parent := store.GetByTid(parentID)
|
pred := store.GetByTid(predID)
|
||||||
if parent == nil {
|
if pred == nil {
|
||||||
return fmt.Errorf("ect: parent task not found: %s", parentID)
|
return fmt.Errorf("ect: predecessor task not found: %s", predID)
|
||||||
}
|
}
|
||||||
// parent.iat < child.iat + clock_skew_tolerance => parent.iat - ect.iat <= clock_skew_tolerance
|
// pred.iat < child.iat + clock_skew_tolerance
|
||||||
if parent.Iat >= ect.Iat+int64(cfg.ClockSkewTolerance) {
|
if pred.Iat >= ect.Iat+int64(cfg.ClockSkewTolerance) {
|
||||||
return fmt.Errorf("ect: parent task not earlier than current: %s", parentID)
|
return fmt.Errorf("ect: predecessor task not earlier than current: %s", predID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Acyclicity (and depth limit)
|
// 4. Acyclicity (and depth limit)
|
||||||
visited := make(map[string]struct{})
|
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")
|
return errors.New("ect: circular dependency or depth limit exceeded")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Parent Policy Decision (only when parent has policy claims per spec)
|
// 5. Predecessor Policy Decision (only when predecessor has policy claims in ext per -01)
|
||||||
for _, parentID := range ect.Par {
|
for _, predID := range ect.Pred {
|
||||||
parent := store.GetByTid(parentID)
|
pred := store.GetByTid(predID)
|
||||||
if parent != nil && parent.HasPolicyClaims() &&
|
if pred != nil && pred.HasPolicyClaims() {
|
||||||
(parent.PolDecision == PolDecisionRejected || parent.PolDecision == PolDecisionPendingHumanReview) {
|
polDec := pred.PolDecision()
|
||||||
|
if polDec == "rejected" || polDec == "pending_human_review" {
|
||||||
if !ect.CompensationRequired() {
|
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 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
|
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.
|
// 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 {
|
if len(visited) >= maxDepth {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
for _, parentID := range parentIDs {
|
for _, predID := range predIDs {
|
||||||
if parentID == targetTid {
|
if predID == targetTid {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if _, ok := visited[parentID]; ok {
|
if _, ok := visited[predID]; ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
visited[parentID] = struct{}{}
|
visited[predID] = struct{}{}
|
||||||
parent := store.GetByTid(parentID)
|
pred := store.GetByTid(predID)
|
||||||
if parent != nil {
|
if pred != nil {
|
||||||
if hasCycle(targetTid, parent.Par, store, visited, maxDepth) {
|
if hasCycle(targetTid, pred.Pred, store, visited, maxDepth) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ func TestValidateDAG_Root(t *testing.T) {
|
|||||||
payload := &Payload{
|
payload := &Payload{
|
||||||
Jti: "jti-001",
|
Jti: "jti-001",
|
||||||
Wid: "wf-1",
|
Wid: "wf-1",
|
||||||
Par: []string{},
|
Pred: []string{},
|
||||||
PolDecision: PolDecisionApproved,
|
|
||||||
}
|
}
|
||||||
err := ValidateDAG(payload, store, DefaultDAGConfig())
|
err := ValidateDAG(payload, store, DefaultDAGConfig())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -21,22 +20,21 @@ func TestValidateDAG_Root(t *testing.T) {
|
|||||||
|
|
||||||
func TestValidateDAG_DuplicateJti(t *testing.T) {
|
func TestValidateDAG_DuplicateJti(t *testing.T) {
|
||||||
store := NewMemoryLedger()
|
store := NewMemoryLedger()
|
||||||
_, _ = store.Append("dummy-jws", &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", Par: []string{}, PolDecision: PolDecisionApproved}
|
payload := &Payload{Jti: "jti-001", Wid: "wf-1", Pred: []string{}}
|
||||||
err := ValidateDAG(payload, store, DefaultDAGConfig())
|
err := ValidateDAG(payload, store, DefaultDAGConfig())
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for duplicate jti")
|
t.Fatal("expected error for duplicate jti")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateDAG_ParentExists(t *testing.T) {
|
func TestValidateDAG_PredExists(t *testing.T) {
|
||||||
store := NewMemoryLedger()
|
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{
|
payload := &Payload{
|
||||||
Jti: "jti-002",
|
Jti: "jti-002",
|
||||||
Wid: "wf-1",
|
Wid: "wf-1",
|
||||||
Par: []string{"jti-001"},
|
Pred: []string{"jti-001"},
|
||||||
PolDecision: PolDecisionApproved,
|
|
||||||
Iat: time.Now().Unix(),
|
Iat: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
err := ValidateDAG(payload, store, DefaultDAGConfig())
|
err := ValidateDAG(payload, store, DefaultDAGConfig())
|
||||||
@@ -45,17 +43,16 @@ func TestValidateDAG_ParentExists(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateDAG_ParentNotFound(t *testing.T) {
|
func TestValidateDAG_PredNotFound(t *testing.T) {
|
||||||
store := NewMemoryLedger()
|
store := NewMemoryLedger()
|
||||||
payload := &Payload{
|
payload := &Payload{
|
||||||
Jti: "jti-002",
|
Jti: "jti-002",
|
||||||
Par: []string{"jti-missing"},
|
Pred: []string{"jti-missing"},
|
||||||
PolDecision: PolDecisionApproved,
|
|
||||||
Iat: time.Now().Unix(),
|
Iat: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
err := ValidateDAG(payload, store, DefaultDAGConfig())
|
err := ValidateDAG(payload, store, DefaultDAGConfig())
|
||||||
if err == nil {
|
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()
|
store := NewMemoryLedger()
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
// Chain: jti-1 -> jti-2 -> jti-3 -> ...; validate with maxAncestorLimit=2 so we exceed it
|
// 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("jws1", &Payload{Jti: "jti-1", Wid: "wf", Pred: []string{}, Iat: now - 100})
|
||||||
_, _ = store.Append("jws2", &Payload{Jti: "jti-2", Wid: "wf", Par: []string{"jti-1"}, PolDecision: PolDecisionApproved, Iat: now - 50})
|
_, _ = store.Append("jws2", &Payload{Jti: "jti-2", Wid: "wf", Pred: []string{"jti-1"}, Iat: now - 50})
|
||||||
cfg := DAGConfig{ClockSkewTolerance: DefaultClockSkewTolerance, MaxAncestorLimit: 2}
|
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)
|
err := ValidateDAG(payload, store, cfg)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error when ancestor limit exceeded")
|
t.Fatal("expected error when ancestor limit exceeded")
|
||||||
@@ -74,7 +71,7 @@ func TestValidateDAG_DepthLimit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateDAG_StoreNil(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())
|
err := ValidateDAG(payload, nil, DefaultDAGConfig())
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error when store is 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) {
|
func TestValidateDAG_TemporalOrdering(t *testing.T) {
|
||||||
store := NewMemoryLedger()
|
store := NewMemoryLedger()
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
_, _ = store.Append("jws1", &Payload{Jti: "jti-1", Wid: "wf", Par: []string{}, PolDecision: PolDecisionApproved, Iat: now})
|
_, _ = store.Append("jws1", &Payload{Jti: "jti-1", Wid: "wf", Pred: []string{}, Iat: now})
|
||||||
// child has iat before parent + skew: parent.iat (now) >= child.iat (now+100) + 30 => invalid
|
// child has iat after pred: valid
|
||||||
payload := &Payload{Jti: "jti-2", Wid: "wf", Par: []string{"jti-1"}, PolDecision: PolDecisionApproved, Iat: now + 100}
|
payload := &Payload{Jti: "jti-2", Wid: "wf", Pred: []string{"jti-1"}, Iat: now + 100}
|
||||||
err := ValidateDAG(payload, store, DAGConfig{ClockSkewTolerance: 30, MaxAncestorLimit: 10000})
|
err := ValidateDAG(payload, store, DAGConfig{ClockSkewTolerance: 30, MaxAncestorLimit: 10000})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// parent.iat >= child.iat + skew: parent at now+50, child at now+10, skew 30 => 50 >= 40 => invalid
|
// 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", Par: []string{}, PolDecision: PolDecisionApproved, Iat: now + 50})
|
_, _ = store.Append("jws2", &Payload{Jti: "jti-1b", Wid: "wf", Pred: []string{}, Iat: now + 50})
|
||||||
payload2 := &Payload{Jti: "jti-2b", Wid: "wf", Par: []string{"jti-1b"}, PolDecision: PolDecisionApproved, Iat: now + 10}
|
payload2 := &Payload{Jti: "jti-2b", Wid: "wf", Pred: []string{"jti-1b"}, Iat: now + 10}
|
||||||
err = ValidateDAG(payload2, store, DAGConfig{ClockSkewTolerance: 30, MaxAncestorLimit: 10000})
|
err = ValidateDAG(payload2, store, DAGConfig{ClockSkewTolerance: 30, MaxAncestorLimit: 10000})
|
||||||
if err == nil {
|
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) {
|
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()
|
store := NewMemoryLedger()
|
||||||
now := time.Now().Unix()
|
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())
|
err := ValidateDAG(payload, store, DefaultDAGConfig())
|
||||||
if err == nil {
|
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) {
|
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()
|
store := NewMemoryLedger()
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
_, _ = store.Append("jws1", &Payload{Jti: "jti-a", Wid: "wf", Par: []string{}, PolDecision: PolDecisionApproved, Iat: now - 10})
|
_, _ = store.Append("jws1", &Payload{Jti: "jti-a", Wid: "wf", Pred: []string{}, Iat: now - 10})
|
||||||
payload := &Payload{Jti: "jti-b", Wid: "wf", Par: []string{"jti-a", "jti-a"}, PolDecision: PolDecisionApproved, Iat: now}
|
payload := &Payload{Jti: "jti-b", Wid: "wf", Pred: []string{"jti-a", "jti-a"}, Iat: now}
|
||||||
err := ValidateDAG(payload, store, DefaultDAGConfig())
|
err := ValidateDAG(payload, store, DefaultDAGConfig())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateDAG_ParentPolicyRejected_RequiresCompensation(t *testing.T) {
|
func TestValidateDAG_PredPolicyRejected_RequiresCompensation(t *testing.T) {
|
||||||
store := NewMemoryLedger()
|
store := NewMemoryLedger()
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
_, _ = store.Append("jws1", &Payload{Jti: "jti-rej", Wid: "wf", Par: []string{}, Pol: "p", PolDecision: PolDecisionRejected, Iat: now - 60})
|
_, _ = store.Append("jws1", &Payload{
|
||||||
payload := &Payload{Jti: "jti-child", Wid: "wf", Par: []string{"jti-rej"}, PolDecision: PolDecisionApproved, Iat: now}
|
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())
|
err := ValidateDAG(payload, store, DefaultDAGConfig())
|
||||||
if err == nil {
|
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}
|
payload.Ext = map[string]interface{}{"compensation_required": true}
|
||||||
err = ValidateDAG(payload, store, DefaultDAGConfig())
|
err = ValidateDAG(payload, store, DefaultDAGConfig())
|
||||||
|
|||||||
@@ -15,15 +15,13 @@ var (
|
|||||||
ErrExpired = errors.New("ect: token expired")
|
ErrExpired = errors.New("ect: token expired")
|
||||||
ErrIATTooOld = errors.New("ect: iat too far in the past")
|
ErrIATTooOld = errors.New("ect: iat too far in the past")
|
||||||
ErrIATInFuture = errors.New("ect: iat in the future")
|
ErrIATInFuture = errors.New("ect: iat in the future")
|
||||||
ErrMissingClaims = errors.New("ect: missing required claims (jti, exec_act, par)")
|
ErrMissingClaims = errors.New("ect: missing required claims (jti, exec_act, pred)")
|
||||||
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)")
|
ErrReplay = errors.New("ect: jti already seen (replay)")
|
||||||
ErrResolveKeyRequired = errors.New("ect: ResolveKey required")
|
ErrResolveKeyRequired = errors.New("ect: ResolveKey required")
|
||||||
ErrExtSize = errors.New("ect: ext exceeds max size (4096 bytes)")
|
ErrExtSize = errors.New("ect: ext exceeds max size (4096 bytes)")
|
||||||
ErrExtDepth = errors.New("ect: ext exceeds max nesting depth (5)")
|
ErrExtDepth = errors.New("ect: ext exceeds max nesting depth (5)")
|
||||||
ErrInvalidJTI = errors.New("ect: jti must be UUID format")
|
ErrInvalidJTI = errors.New("ect: jti must be UUID format")
|
||||||
ErrInvalidWID = errors.New("ect: wid must be UUID format when set")
|
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:...)")
|
ErrHashFormat = errors.New("ect: inp_hash/out_hash must be plain base64url (no prefix)")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ type LedgerEntry struct {
|
|||||||
TaskID string `json:"task_id"`
|
TaskID string `json:"task_id"`
|
||||||
AgentID string `json:"agent_id"`
|
AgentID string `json:"agent_id"`
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
Parents []string `json:"parents"`
|
Predecessors []string `json:"predecessors"`
|
||||||
ECTJWS string `json:"ect_jws"`
|
ECTJWS string `json:"ect_jws"`
|
||||||
SignatureVerified bool `json:"signature_verified"`
|
SignatureVerified bool `json:"signature_verified"`
|
||||||
VerificationTime time.Time `json:"verification_timestamp"`
|
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
|
TaskID: payload.Jti, // task id = jti per spec
|
||||||
AgentID: payload.Iss,
|
AgentID: payload.Iss,
|
||||||
Action: payload.ExecAct,
|
Action: payload.ExecAct,
|
||||||
Parents: append([]string(nil), payload.Par...),
|
Predecessors: append([]string(nil), payload.Pred...),
|
||||||
ECTJWS: ectJWS,
|
ECTJWS: ectJWS,
|
||||||
SignatureVerified: true,
|
SignatureVerified: true,
|
||||||
VerificationTime: time.Now().UTC(),
|
VerificationTime: time.Now().UTC(),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
func TestMemoryLedger_AppendAndGet(t *testing.T) {
|
func TestMemoryLedger_AppendAndGet(t *testing.T) {
|
||||||
m := NewMemoryLedger()
|
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)
|
seq, err := m.Append("jws1", p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -23,7 +23,7 @@ func TestMemoryLedger_AppendAndGet(t *testing.T) {
|
|||||||
|
|
||||||
func TestMemoryLedger_ErrTaskIDExists(t *testing.T) {
|
func TestMemoryLedger_ErrTaskIDExists(t *testing.T) {
|
||||||
m := NewMemoryLedger()
|
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)
|
_, _ = m.Append("jws1", p)
|
||||||
_, err := m.Append("jws2", p)
|
_, err := m.Append("jws2", p)
|
||||||
if err != ErrTaskIDExists {
|
if err != ErrTaskIDExists {
|
||||||
@@ -33,7 +33,7 @@ func TestMemoryLedger_ErrTaskIDExists(t *testing.T) {
|
|||||||
|
|
||||||
func TestMemoryLedger_ContainsWid(t *testing.T) {
|
func TestMemoryLedger_ContainsWid(t *testing.T) {
|
||||||
m := NewMemoryLedger()
|
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)
|
_, _ = m.Append("jws", p)
|
||||||
if !m.Contains("j1", "") {
|
if !m.Contains("j1", "") {
|
||||||
t.Error("Contains(j1, \"\") should be true")
|
t.Error("Contains(j1, \"\") should be true")
|
||||||
|
|||||||
@@ -1,24 +1,20 @@
|
|||||||
// Package ect implements Execution Context Tokens (ECTs) per
|
// Package ect implements Execution Context Tokens (ECTs) per
|
||||||
// draft-nennemann-wimse-execution-context-00.
|
// draft-nennemann-wimse-execution-context-01.
|
||||||
package ect
|
package ect
|
||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// ECTType is the JOSE typ value for ECTs.
|
// ECTType is the preferred JOSE typ value for ECTs per -01.
|
||||||
const ECTType = "wimse-exec+jwt"
|
// ECTTypeLegacy is accepted for backward compatibility with -00.
|
||||||
|
|
||||||
// PolDecision values per Section 4.2.3.
|
|
||||||
const (
|
const (
|
||||||
PolDecisionApproved = "approved"
|
ECTType = "exec+jwt"
|
||||||
PolDecisionRejected = "rejected"
|
ECTTypeLegacy = "wimse-exec+jwt"
|
||||||
PolDecisionPendingHumanReview = "pending_human_review"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Payload holds ECT JWT claims per Section 4.2.
|
// Payload holds ECT JWT claims per Section 4.2.
|
||||||
type Payload struct {
|
type Payload struct {
|
||||||
// Standard JWT claims (required unless noted)
|
// Standard JWT claims (required unless noted)
|
||||||
Iss string `json:"iss"` // REQUIRED: issuer, SPIFFE ID
|
Iss string `json:"iss"` // REQUIRED: issuer, SPIFFE ID
|
||||||
Sub string `json:"sub,omitempty"`
|
|
||||||
Aud Audience `json:"aud"` // REQUIRED
|
Aud Audience `json:"aud"` // REQUIRED
|
||||||
Iat int64 `json:"iat"` // REQUIRED: NumericDate
|
Iat int64 `json:"iat"` // REQUIRED: NumericDate
|
||||||
Exp int64 `json:"exp"` // REQUIRED
|
Exp int64 `json:"exp"` // REQUIRED
|
||||||
@@ -28,13 +24,7 @@ type Payload struct {
|
|||||||
// Task identity is jti only; no separate "tid" claim per spec.
|
// Task identity is jti only; no separate "tid" claim per spec.
|
||||||
Wid string `json:"wid,omitempty"` // OPTIONAL: workflow ID, UUID
|
Wid string `json:"wid,omitempty"` // OPTIONAL: workflow ID, UUID
|
||||||
ExecAct string `json:"exec_act"` // REQUIRED
|
ExecAct string `json:"exec_act"` // REQUIRED
|
||||||
Par []string `json:"par"` // REQUIRED: parent jti values
|
Pred []string `json:"pred"` // REQUIRED: predecessor jti values (renamed from par in -01)
|
||||||
|
|
||||||
// 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)
|
// Data integrity (Section 4.2.4)
|
||||||
InpHash string `json:"inp_hash,omitempty"`
|
InpHash string `json:"inp_hash,omitempty"`
|
||||||
@@ -42,8 +32,9 @@ type Payload struct {
|
|||||||
InpClassification string `json:"inp_classification,omitempty"`
|
InpClassification string `json:"inp_classification,omitempty"`
|
||||||
|
|
||||||
// Extensions (Section 4.2.7): exec_time_ms, regulated_domain, model_version,
|
// Extensions (Section 4.2.7): exec_time_ms, regulated_domain, model_version,
|
||||||
// witnessed_by, inp_classification, pol_timestamp, compensation_required, compensation_reason
|
// witnessed_by, inp_classification, compensation_required, compensation_reason,
|
||||||
Ext map[string]interface{} `json:"ext,omitempty"`
|
// 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.
|
// Audience is aud claim: string or array of strings.
|
||||||
@@ -62,11 +53,6 @@ func (a *Audience) UnmarshalJSON(data []byte) error {
|
|||||||
return unmarshalAudience(data, a)
|
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.
|
// ContainsAudience returns true if verifierID is in the audience.
|
||||||
func (p *Payload) ContainsAudience(verifierID string) bool {
|
func (p *Payload) ContainsAudience(verifierID string) bool {
|
||||||
for _, id := range p.Aud {
|
for _, id := range p.Aud {
|
||||||
@@ -96,7 +82,21 @@ func (p *Payload) CompensationRequired() bool {
|
|||||||
return v
|
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 {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestPayload_ContainsAudience(t *testing.T) {
|
||||||
p := &Payload{Aud: []string{"a", "b"}}
|
p := &Payload{Aud: []string{"a", "b"}}
|
||||||
if !p.ContainsAudience("a") {
|
if !p.ContainsAudience("a") {
|
||||||
@@ -104,12 +92,27 @@ func TestPayload_CompensationRequired(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPayload_HasPolicyClaims(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() {
|
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() {
|
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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExtMaxSize is the recommended max serialized size of ext (Section 4.2.7).
|
// ExtMaxSize is the recommended max serialized size of ext (Section 4.2.7).
|
||||||
@@ -13,14 +12,14 @@ const ExtMaxSize = 4096
|
|||||||
// ExtMaxDepth is the recommended max JSON nesting depth in ext.
|
// ExtMaxDepth is the recommended max JSON nesting depth in ext.
|
||||||
const ExtMaxDepth = 5
|
const ExtMaxDepth = 5
|
||||||
|
|
||||||
// DefaultMaxParLength is the recommended max number of parent references.
|
// DefaultMaxPredLength is the recommended max number of predecessor references.
|
||||||
const DefaultMaxParLength = 100
|
const DefaultMaxPredLength = 100
|
||||||
|
|
||||||
// uuidRegex matches RFC 9562 UUID: 8-4-4-4-12 hex.
|
// 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}$`)
|
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.
|
// base64urlRegex matches a non-empty base64url string without padding.
|
||||||
var allowedHashAlgs = map[string]bool{"sha-256": true, "sha-384": true, "sha-512": true}
|
var base64urlRegex = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
||||||
|
|
||||||
// ValidateExt returns an error if ext exceeds ExtMaxSize or ExtMaxDepth.
|
// ValidateExt returns an error if ext exceeds ExtMaxSize or ExtMaxDepth.
|
||||||
func ValidateExt(ext map[string]interface{}) error {
|
func ValidateExt(ext map[string]interface{}) error {
|
||||||
@@ -61,23 +60,18 @@ func ValidUUID(s string) bool {
|
|||||||
return uuidRegex.MatchString(s)
|
return uuidRegex.MatchString(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateHashFormat returns nil if s is empty or matches "algorithm:base64url" (sha-256, sha-384, sha-512).
|
// ValidateHashFormat returns nil if s is empty or is plain base64url (no padding)
|
||||||
|
// per draft-nennemann-wimse-ect-01 and RFC 9449 (no algorithm prefix).
|
||||||
func ValidateHashFormat(s string) error {
|
func ValidateHashFormat(s string) error {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
idx := strings.Index(s, ":")
|
if !base64urlRegex.MatchString(s) {
|
||||||
if idx <= 0 {
|
|
||||||
return ErrHashFormat
|
return ErrHashFormat
|
||||||
}
|
}
|
||||||
alg := strings.ToLower(s[:idx])
|
_, err := base64.RawURLEncoding.DecodeString(s)
|
||||||
if !allowedHashAlgs[alg] {
|
if err != nil {
|
||||||
return ErrHashFormat
|
return ErrHashFormat
|
||||||
}
|
}
|
||||||
encoded := s[idx+1:]
|
return nil
|
||||||
if encoded == "" {
|
|
||||||
return ErrHashFormat
|
|
||||||
}
|
|
||||||
_, err := base64.RawURLEncoding.DecodeString(encoded)
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ type VerifyOptions struct {
|
|||||||
WITSubject string
|
WITSubject string
|
||||||
// ValidateUUIDs when true requires jti and wid (if set) to be UUID format.
|
// ValidateUUIDs when true requires jti and wid (if set) to be UUID format.
|
||||||
ValidateUUIDs bool
|
ValidateUUIDs bool
|
||||||
// MaxParLength caps par length (0 = no limit). Applied before DAG; DAG may also enforce via DAG.MaxParLength.
|
// MaxPredLength caps pred length (0 = no limit). Applied before DAG; DAG may also enforce via DAG.MaxPredLength.
|
||||||
MaxParLength int
|
MaxPredLength int
|
||||||
// LogVerify if set is called after verification with jti and any error (for observability).
|
// LogVerify if set is called after verification with jti and any error (for observability).
|
||||||
LogVerify func(jti string, err error)
|
LogVerify func(jti string, err error)
|
||||||
}
|
}
|
||||||
@@ -104,12 +104,13 @@ func Verify(compact string, opts VerifyOptions) (parsed *ParsedECT, err error) {
|
|||||||
sig := jws.Signatures[0]
|
sig := jws.Signatures[0]
|
||||||
header := &sig.Header
|
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)
|
typ, _ := header.ExtraHeaders["typ"].(string)
|
||||||
if typ == "" {
|
if typ == "" {
|
||||||
typ, _ = header.ExtraHeaders[jose.HeaderType].(string)
|
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
|
return nil, ErrInvalidTyp
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,15 +183,15 @@ func Verify(compact string, opts VerifyOptions) (parsed *ParsedECT, err error) {
|
|||||||
return nil, ErrIATInFuture
|
return nil, ErrIATInFuture
|
||||||
}
|
}
|
||||||
|
|
||||||
// 12. Required claims present (jti, exec_act, par)
|
// 12. Required claims present (jti, exec_act, pred)
|
||||||
if p.Jti == "" || p.ExecAct == "" {
|
if p.Jti == "" || p.ExecAct == "" {
|
||||||
return nil, ErrMissingClaims
|
return nil, ErrMissingClaims
|
||||||
}
|
}
|
||||||
if p.Par == nil {
|
if p.Pred == nil {
|
||||||
p.Par = []string{}
|
p.Pred = []string{}
|
||||||
}
|
}
|
||||||
if opts.MaxParLength > 0 && len(p.Par) > opts.MaxParLength {
|
if opts.MaxPredLength > 0 && len(p.Pred) > opts.MaxPredLength {
|
||||||
return nil, ErrParLength
|
return nil, ErrPredLength
|
||||||
}
|
}
|
||||||
if opts.ValidateUUIDs {
|
if opts.ValidateUUIDs {
|
||||||
if !ValidUUID(p.Jti) {
|
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
|
// 13. DAG validation
|
||||||
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 opts.Store != nil {
|
||||||
if err := ValidateDAG(&p, opts.Store, opts.DAG); err != nil {
|
if err := ValidateDAG(&p, opts.Store, opts.DAG); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 15. Replay (jti seen)
|
// 14. Replay (jti seen)
|
||||||
if opts.JTISeen != nil && opts.JTISeen(p.Jti) {
|
if opts.JTISeen != nil && opts.JTISeen(p.Jti) {
|
||||||
return nil, ErrReplay
|
return nil, ErrReplay
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,9 +17,7 @@ func TestParse(t *testing.T) {
|
|||||||
Exp: now.Add(time.Hour).Unix(),
|
Exp: now.Add(time.Hour).Unix(),
|
||||||
Jti: "jti-parse",
|
Jti: "jti-parse",
|
||||||
ExecAct: "act",
|
ExecAct: "act",
|
||||||
Par: []string{},
|
Pred: []string{},
|
||||||
Pol: "pol",
|
|
||||||
PolDecision: PolDecisionApproved,
|
|
||||||
}
|
}
|
||||||
compact, err := Create(payload, key, CreateOptions{KeyID: "kid"})
|
compact, err := Create(payload, key, CreateOptions{KeyID: "kid"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -56,9 +54,7 @@ func TestVerify_Expired(t *testing.T) {
|
|||||||
Exp: now.Add(-1 * time.Minute).Unix(),
|
Exp: now.Add(-1 * time.Minute).Unix(),
|
||||||
Jti: "jti-exp",
|
Jti: "jti-exp",
|
||||||
ExecAct: "act",
|
ExecAct: "act",
|
||||||
Par: []string{},
|
Pred: []string{},
|
||||||
Pol: "pol",
|
|
||||||
PolDecision: PolDecisionApproved,
|
|
||||||
}
|
}
|
||||||
compact, _ := Create(payload, key, CreateOptions{KeyID: "kid"})
|
compact, _ := Create(payload, key, CreateOptions{KeyID: "kid"})
|
||||||
resolver := func(kid string) (*ecdsa.PublicKey, error) {
|
resolver := func(kid string) (*ecdsa.PublicKey, error) {
|
||||||
@@ -87,9 +83,7 @@ func TestVerify_Replay(t *testing.T) {
|
|||||||
Exp: now.Add(time.Hour).Unix(),
|
Exp: now.Add(time.Hour).Unix(),
|
||||||
Jti: "jti-replay",
|
Jti: "jti-replay",
|
||||||
ExecAct: "act",
|
ExecAct: "act",
|
||||||
Par: []string{},
|
Pred: []string{},
|
||||||
Pol: "p",
|
|
||||||
PolDecision: PolDecisionApproved,
|
|
||||||
}
|
}
|
||||||
compact, _ := Create(payload, key, CreateOptions{KeyID: "kid"})
|
compact, _ := Create(payload, key, CreateOptions{KeyID: "kid"})
|
||||||
resolver := func(kid string) (*ecdsa.PublicKey, error) {
|
resolver := func(kid string) (*ecdsa.PublicKey, error) {
|
||||||
@@ -125,7 +119,7 @@ func TestVerify_WITSubjectMismatch(t *testing.T) {
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
payload := &Payload{
|
payload := &Payload{
|
||||||
Iss: "iss", Aud: []string{"v"}, Iat: now.Unix(), Exp: now.Add(time.Hour).Unix(),
|
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"})
|
compact, _ := Create(payload, key, CreateOptions{KeyID: "kid"})
|
||||||
resolver := func(kid string) (*ecdsa.PublicKey, error) {
|
resolver := func(kid string) (*ecdsa.PublicKey, error) {
|
||||||
@@ -147,7 +141,7 @@ func TestVerify_IATTooFarPast(t *testing.T) {
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
payload := &Payload{
|
payload := &Payload{
|
||||||
Iss: "iss", Aud: []string{"v"}, Iat: now.Add(-1 * time.Hour).Unix(), Exp: now.Add(time.Hour).Unix(),
|
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"})
|
compact, _ := Create(payload, key, CreateOptions{KeyID: "kid"})
|
||||||
resolver := func(kid string) (*ecdsa.PublicKey, error) {
|
resolver := func(kid string) (*ecdsa.PublicKey, error) {
|
||||||
@@ -169,7 +163,7 @@ func TestVerify_IATInFuture(t *testing.T) {
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
payload := &Payload{
|
payload := &Payload{
|
||||||
Iss: "iss", Aud: []string{"v"}, Iat: now.Add(60 * time.Second).Unix(), Exp: now.Add(2 * time.Hour).Unix(),
|
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"})
|
compact, _ := Create(payload, key, CreateOptions{KeyID: "kid"})
|
||||||
resolver := func(kid string) (*ecdsa.PublicKey, error) {
|
resolver := func(kid string) (*ecdsa.PublicKey, error) {
|
||||||
@@ -191,7 +185,7 @@ func TestVerify_ResolveKeyError(t *testing.T) {
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
payload := &Payload{
|
payload := &Payload{
|
||||||
Iss: "iss", Aud: []string{"v"}, Iat: now.Unix(), Exp: now.Add(time.Hour).Unix(),
|
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"})
|
compact, _ := Create(payload, key, CreateOptions{KeyID: "kid"})
|
||||||
resolver := func(kid string) (*ecdsa.PublicKey, error) {
|
resolver := func(kid string) (*ecdsa.PublicKey, error) {
|
||||||
@@ -211,7 +205,7 @@ func TestVerify_WithDAG(t *testing.T) {
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
root := &Payload{
|
root := &Payload{
|
||||||
Iss: "iss", Aud: []string{"v"}, Iat: now.Unix(), Exp: now.Add(time.Hour).Unix(),
|
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"})
|
compactRoot, _ := Create(root, key, CreateOptions{KeyID: "kid"})
|
||||||
resolver := func(kid string) (*ecdsa.PublicKey, error) {
|
resolver := func(kid string) (*ecdsa.PublicKey, error) {
|
||||||
@@ -230,7 +224,7 @@ func TestVerify_WithDAG(t *testing.T) {
|
|||||||
_, _ = ledger.Append(compactRoot, parsed.Payload)
|
_, _ = ledger.Append(compactRoot, parsed.Payload)
|
||||||
child := &Payload{
|
child := &Payload{
|
||||||
Iss: "iss", Aud: []string{"v"}, Iat: now.Unix() + 1, Exp: now.Add(time.Hour).Unix(),
|
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"})
|
compactChild, _ := Create(child, key, CreateOptions{KeyID: "kid"})
|
||||||
parsed2, err := Verify(compactChild, opts)
|
parsed2, err := Verify(compactChild, opts)
|
||||||
|
|||||||
@@ -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"}}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# WIMSE ECT — Python Reference Implementation
|
# 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
|
## Layout
|
||||||
|
|
||||||
@@ -42,7 +42,6 @@ from ect import (
|
|||||||
verify,
|
verify,
|
||||||
VerifyOptions,
|
VerifyOptions,
|
||||||
MemoryLedger,
|
MemoryLedger,
|
||||||
POL_DECISION_APPROVED,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
cfg = load_config_from_env()
|
cfg = load_config_from_env()
|
||||||
@@ -54,9 +53,11 @@ payload = Payload(
|
|||||||
exp=int(time.time()) + 600,
|
exp=int(time.time()) + 600,
|
||||||
jti="550e8400-e29b-41d4-a716-446655440000",
|
jti="550e8400-e29b-41d4-a716-446655440000",
|
||||||
exec_act="review_spec",
|
exec_act="review_spec",
|
||||||
par=[],
|
pred=[],
|
||||||
pol="policy_v1",
|
ext={
|
||||||
pol_decision=POL_DECISION_APPROVED,
|
"pol": "policy_v1",
|
||||||
|
"pol_decision": "approved",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
compact = create(payload, key, cfg.create_options("agent-a-key"))
|
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.
|
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)
|
## 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`.
|
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`.
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ from ect import (
|
|||||||
verify,
|
verify,
|
||||||
VerifyOptions,
|
VerifyOptions,
|
||||||
MemoryLedger,
|
MemoryLedger,
|
||||||
POL_DECISION_APPROVED,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -33,9 +32,11 @@ def main():
|
|||||||
jti=root_jti,
|
jti=root_jti,
|
||||||
wid="wf-demo-001",
|
wid="wf-demo-001",
|
||||||
exec_act="review_requirements_spec",
|
exec_act="review_requirements_spec",
|
||||||
par=[],
|
pred=[],
|
||||||
pol="spec_review_policy_v2",
|
ext={
|
||||||
pol_decision=POL_DECISION_APPROVED,
|
"pol": "spec_review_policy_v2",
|
||||||
|
"pol_decision": "approved",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
ect_a = create(payload_a, key_a, CreateOptions(key_id=kid_a))
|
ect_a = create(payload_a, key_a, CreateOptions(key_id=kid_a))
|
||||||
print("Agent A created root ECT (jti=550e8400-..., review_requirements_spec)")
|
print("Agent A created root ECT (jti=550e8400-..., review_requirements_spec)")
|
||||||
@@ -56,7 +57,7 @@ def main():
|
|||||||
ledger.append(ect_a, parsed.payload)
|
ledger.append(ect_a, parsed.payload)
|
||||||
print("Agent B verified root ECT and appended to ledger")
|
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()
|
key_b = generate_key()
|
||||||
kid_b = "agent-b-key"
|
kid_b = "agent-b-key"
|
||||||
child_jti = "550e8400-e29b-41d4-a716-446655440002"
|
child_jti = "550e8400-e29b-41d4-a716-446655440002"
|
||||||
@@ -68,12 +69,14 @@ def main():
|
|||||||
jti=child_jti,
|
jti=child_jti,
|
||||||
wid="wf-demo-001",
|
wid="wf-demo-001",
|
||||||
exec_act="implement_module",
|
exec_act="implement_module",
|
||||||
par=[root_jti],
|
pred=[root_jti],
|
||||||
pol="coding_standards_v3",
|
ext={
|
||||||
pol_decision=POL_DECISION_APPROVED,
|
"pol": "coding_standards_v3",
|
||||||
|
"pol_decision": "approved",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
ect_b = create(payload_b, key_b, CreateOptions(key_id=kid_b))
|
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
|
# 4) Verify child ECT with DAG
|
||||||
def resolver_b(kid):
|
def resolver_b(kid):
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
# WIMSE Execution Context Tokens (ECT) — Python reference implementation
|
# WIMSE Execution Context Tokens (ECT) — Python reference implementation
|
||||||
# draft-nennemann-wimse-execution-context-00
|
# draft-nennemann-wimse-execution-context-01
|
||||||
|
|
||||||
from ect.types import (
|
from ect.types import (
|
||||||
ECT_TYPE,
|
ECT_TYPE,
|
||||||
POL_DECISION_APPROVED,
|
ECT_TYPE_LEGACY,
|
||||||
POL_DECISION_REJECTED,
|
|
||||||
POL_DECISION_PENDING_HUMAN_REVIEW,
|
|
||||||
Payload,
|
Payload,
|
||||||
valid_pol_decision,
|
|
||||||
)
|
)
|
||||||
from ect.create import create, generate_key, CreateOptions, default_create_options
|
from ect.create import create, generate_key, CreateOptions, default_create_options
|
||||||
from ect.verify import (
|
from ect.verify import (
|
||||||
@@ -30,11 +27,8 @@ from ect.jti_cache import JTICache, new_jti_cache
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ECT_TYPE",
|
"ECT_TYPE",
|
||||||
"POL_DECISION_APPROVED",
|
"ECT_TYPE_LEGACY",
|
||||||
"POL_DECISION_REJECTED",
|
|
||||||
"POL_DECISION_PENDING_HUMAN_REVIEW",
|
|
||||||
"Payload",
|
"Payload",
|
||||||
"valid_pol_decision",
|
|
||||||
"create",
|
"create",
|
||||||
"generate_key",
|
"generate_key",
|
||||||
"CreateOptions",
|
"CreateOptions",
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ from typing import Optional
|
|||||||
import jwt
|
import jwt
|
||||||
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
|
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 (
|
from ect.validate import (
|
||||||
DEFAULT_MAX_PAR_LENGTH,
|
DEFAULT_MAX_PRED_LENGTH,
|
||||||
validate_ext,
|
validate_ext,
|
||||||
validate_hash_format,
|
validate_hash_format,
|
||||||
valid_uuid,
|
valid_uuid,
|
||||||
@@ -25,7 +25,7 @@ class CreateOptions:
|
|||||||
iat_max_age_sec: int = 900 # 15 min
|
iat_max_age_sec: int = 900 # 15 min
|
||||||
default_expiry_sec: int = 600 # 10 min
|
default_expiry_sec: int = 600 # 10 min
|
||||||
validate_uuids: bool = False
|
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:
|
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")
|
raise ValueError("ect: jti must be UUID format")
|
||||||
if p.wid and not valid_uuid(p.wid):
|
if p.wid and not valid_uuid(p.wid):
|
||||||
raise ValueError("ect: wid must be UUID format when set")
|
raise ValueError("ect: wid must be UUID format when set")
|
||||||
max_par = opts.max_par_length or 0
|
max_pred = opts.max_pred_length or 0
|
||||||
if max_par > 0 and len(p.par) > max_par:
|
if max_pred > 0 and len(p.pred) > max_pred:
|
||||||
raise ValueError("ect: par exceeds max length")
|
raise ValueError("ect: pred exceeds max length")
|
||||||
if p.inp_hash:
|
if p.inp_hash:
|
||||||
validate_hash_format(p.inp_hash)
|
validate_hash_format(p.inp_hash)
|
||||||
if p.out_hash:
|
if p.out_hash:
|
||||||
validate_hash_format(p.out_hash)
|
validate_hash_format(p.out_hash)
|
||||||
validate_ext(p.ext)
|
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
|
# compensation in ext per spec
|
||||||
if p.ext and p.ext.get("compensation_reason") and not p.ext.get("compensation_required"):
|
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")
|
raise ValueError("ect: ext.compensation_reason requires ext.compensation_required true")
|
||||||
@@ -73,8 +65,7 @@ def create(
|
|||||||
opts: CreateOptions,
|
opts: CreateOptions,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Build and sign an ECT. Payload must have required claims; iat/exp can be 0 for defaults.
|
"""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;
|
create() works on a deep copy so the caller's payload is not modified.
|
||||||
pass a copy if the original must stay unchanged.
|
|
||||||
"""
|
"""
|
||||||
if not opts.key_id:
|
if not opts.key_id:
|
||||||
raise ValueError("ect: KeyID required")
|
raise ValueError("ect: KeyID required")
|
||||||
@@ -87,16 +78,14 @@ def create(
|
|||||||
payload.iat = now
|
payload.iat = now
|
||||||
if payload.exp == 0:
|
if payload.exp == 0:
|
||||||
payload.exp = now + (opts.default_expiry_sec or 600)
|
payload.exp = now + (opts.default_expiry_sec or 600)
|
||||||
if not payload.sub:
|
if payload.pred is None:
|
||||||
payload.sub = payload.iss
|
payload.pred = []
|
||||||
if payload.par is None:
|
|
||||||
payload.par = []
|
|
||||||
|
|
||||||
_validate_payload(payload, opts)
|
_validate_payload(payload, opts)
|
||||||
|
|
||||||
claims = payload.to_claims()
|
claims = payload.to_claims()
|
||||||
headers = {
|
headers = {
|
||||||
"typ": "wimse-exec+jwt",
|
"typ": ECT_TYPE,
|
||||||
"alg": "ES256",
|
"alg": "ES256",
|
||||||
"kid": opts.key_id,
|
"kid": opts.key_id,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ect.types import Payload
|
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_CLOCK_SKEW_TOLERANCE = 30
|
||||||
DEFAULT_MAX_ANCESTOR_LIMIT = 10000
|
DEFAULT_MAX_ANCESTOR_LIMIT = 10000
|
||||||
@@ -31,11 +31,11 @@ class DAGConfig:
|
|||||||
self,
|
self,
|
||||||
clock_skew_tolerance: int = DEFAULT_CLOCK_SKEW_TOLERANCE,
|
clock_skew_tolerance: int = DEFAULT_CLOCK_SKEW_TOLERANCE,
|
||||||
max_ancestor_limit: int = DEFAULT_MAX_ANCESTOR_LIMIT,
|
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.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_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:
|
def default_dag_config() -> DAGConfig:
|
||||||
@@ -44,22 +44,22 @@ def default_dag_config() -> DAGConfig:
|
|||||||
|
|
||||||
def _has_cycle(
|
def _has_cycle(
|
||||||
target_tid: str,
|
target_tid: str,
|
||||||
parent_ids: list[str],
|
pred_ids: list[str],
|
||||||
store: ECTStore,
|
store: ECTStore,
|
||||||
visited: set[str],
|
visited: set[str],
|
||||||
max_depth: int,
|
max_depth: int,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if len(visited) >= max_depth:
|
if len(visited) >= max_depth:
|
||||||
return True
|
return True
|
||||||
for parent_id in parent_ids:
|
for pred_id in pred_ids:
|
||||||
if parent_id == target_tid:
|
if pred_id == target_tid:
|
||||||
return True
|
return True
|
||||||
if parent_id in visited:
|
if pred_id in visited:
|
||||||
continue
|
continue
|
||||||
visited.add(parent_id)
|
visited.add(pred_id)
|
||||||
parent = store.get_by_tid(parent_id)
|
pred = store.get_by_tid(pred_id)
|
||||||
if parent is not None:
|
if pred is not None:
|
||||||
if _has_cycle(target_tid, parent.par, store, visited, max_depth):
|
if _has_cycle(target_tid, pred.pred, store, visited, max_depth):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -69,29 +69,28 @@ def validate_dag(
|
|||||||
store: ECTStore,
|
store: ECTStore,
|
||||||
cfg: DAGConfig,
|
cfg: DAGConfig,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Section 6.2: uniqueness (by jti), parent existence, temporal ordering, acyclicity, parent policy."""
|
"""Section 6.2: uniqueness (by jti), predecessor existence, temporal ordering, acyclicity, predecessor policy."""
|
||||||
if cfg.max_par_length > 0 and len(payload.par) > cfg.max_par_length:
|
if cfg.max_pred_length > 0 and len(payload.pred) > cfg.max_pred_length:
|
||||||
raise ValueError("ect: par exceeds max length")
|
raise ValueError("ect: pred exceeds max length")
|
||||||
if store.contains(payload.jti, payload.wid or ""):
|
if store.contains(payload.jti, payload.wid or ""):
|
||||||
raise ValueError(f"ect: task ID (jti) already exists: {payload.jti}")
|
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:
|
for pred_id in payload.pred:
|
||||||
parent = store.get_by_tid(parent_id)
|
pred = store.get_by_tid(pred_id)
|
||||||
if parent is None:
|
if pred is None:
|
||||||
raise ValueError(f"ect: parent task not found: {parent_id}")
|
raise ValueError(f"ect: predecessor task not found: {pred_id}")
|
||||||
if parent.iat >= payload.iat + cfg.clock_skew_tolerance:
|
if pred.iat >= payload.iat + cfg.clock_skew_tolerance:
|
||||||
raise ValueError(f"ect: parent task not earlier than current: {parent_id}")
|
raise ValueError(f"ect: predecessor task not earlier than current: {pred_id}")
|
||||||
|
|
||||||
visited: set[str] = set()
|
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")
|
raise ValueError("ect: circular dependency or depth limit exceeded")
|
||||||
|
|
||||||
# Parent policy decision: only when parent has policy claims per spec
|
# Predecessor policy decision: only when predecessor has policy claims in ext per -01
|
||||||
for parent_id in payload.par:
|
for pred_id in payload.pred:
|
||||||
parent = store.get_by_tid(parent_id)
|
pred = store.get_by_tid(pred_id)
|
||||||
if parent and parent.has_policy_claims() and parent.pol_decision in (POL_DECISION_REJECTED, POL_DECISION_PENDING_HUMAN_REVIEW):
|
if pred and pred.has_policy_claims() and pred.pol_decision() in ("rejected", "pending_human_review"):
|
||||||
if not payload.compensation_required():
|
if not payload.compensation_required():
|
||||||
raise ValueError(
|
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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class LedgerEntry:
|
|||||||
task_id: str
|
task_id: str
|
||||||
agent_id: str
|
agent_id: str
|
||||||
action: str
|
action: str
|
||||||
parents: list[str]
|
predecessors: list[str]
|
||||||
ect_jws: str
|
ect_jws: str
|
||||||
signature_verified: bool
|
signature_verified: bool
|
||||||
verification_timestamp: float
|
verification_timestamp: float
|
||||||
@@ -70,7 +70,7 @@ class MemoryLedger(Ledger):
|
|||||||
task_id=payload.jti,
|
task_id=payload.jti,
|
||||||
agent_id=payload.iss,
|
agent_id=payload.iss,
|
||||||
action=payload.exec_act,
|
action=payload.exec_act,
|
||||||
parents=list(payload.par) if payload.par else [],
|
predecessors=list(payload.pred) if payload.pred else [],
|
||||||
ect_jws=ect_jws,
|
ect_jws=ect_jws,
|
||||||
signature_verified=True,
|
signature_verified=True,
|
||||||
verification_timestamp=now,
|
verification_timestamp=now,
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -6,19 +6,9 @@ import json
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
ECT_TYPE = "wimse-exec+jwt"
|
# Preferred typ per -01; legacy accepted for backward compatibility.
|
||||||
|
ECT_TYPE = "exec+jwt"
|
||||||
POL_DECISION_APPROVED = "approved"
|
ECT_TYPE_LEGACY = "wimse-exec+jwt"
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _audience_serialize(aud: list[str]) -> str | list[str]:
|
def _audience_serialize(aud: list[str]) -> str | list[str]:
|
||||||
@@ -45,20 +35,15 @@ class Payload:
|
|||||||
exp: int
|
exp: int
|
||||||
jti: str
|
jti: str
|
||||||
exec_act: str
|
exec_act: str
|
||||||
par: list[str]
|
pred: list[str] # predecessor jti values (renamed from par in -01)
|
||||||
pol: str = ""
|
|
||||||
pol_decision: str = ""
|
|
||||||
sub: str = ""
|
|
||||||
wid: str = ""
|
wid: str = ""
|
||||||
pol_enforcer: str = ""
|
|
||||||
pol_timestamp: int = 0
|
|
||||||
inp_hash: str = ""
|
inp_hash: str = ""
|
||||||
out_hash: str = ""
|
out_hash: str = ""
|
||||||
inp_classification: str = ""
|
inp_classification: str = ""
|
||||||
ext: dict[str, Any] = field(default_factory=dict)
|
ext: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
def to_claims(self) -> dict[str, Any]:
|
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] = {
|
out: dict[str, Any] = {
|
||||||
"iss": self.iss,
|
"iss": self.iss,
|
||||||
"aud": _audience_serialize(self.aud),
|
"aud": _audience_serialize(self.aud),
|
||||||
@@ -66,20 +51,10 @@ class Payload:
|
|||||||
"exp": self.exp,
|
"exp": self.exp,
|
||||||
"jti": self.jti,
|
"jti": self.jti,
|
||||||
"exec_act": self.exec_act,
|
"exec_act": self.exec_act,
|
||||||
"par": self.par,
|
"pred": self.pred,
|
||||||
}
|
}
|
||||||
if self.sub:
|
|
||||||
out["sub"] = self.sub
|
|
||||||
if self.wid:
|
if self.wid:
|
||||||
out["wid"] = 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:
|
if self.inp_hash:
|
||||||
out["inp_hash"] = self.inp_hash
|
out["inp_hash"] = self.inp_hash
|
||||||
if self.out_hash:
|
if self.out_hash:
|
||||||
@@ -87,13 +62,13 @@ class Payload:
|
|||||||
if self.inp_classification:
|
if self.inp_classification:
|
||||||
out["inp_classification"] = self.inp_classification
|
out["inp_classification"] = self.inp_classification
|
||||||
if self.ext:
|
if self.ext:
|
||||||
out["ext"] = dict(self.ext)
|
out["ect_ext"] = dict(self.ext)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_claims(cls, claims: dict[str, Any]) -> Payload:
|
def from_claims(cls, claims: dict[str, Any]) -> Payload:
|
||||||
"""Build Payload from JWT claims. Compensation read from ext per spec."""
|
"""Build Payload from JWT claims. Policy claims read from ext per -01 spec."""
|
||||||
ext = claims.get("ext") or {}
|
ext = claims.get("ect_ext") or {}
|
||||||
return cls(
|
return cls(
|
||||||
iss=claims["iss"],
|
iss=claims["iss"],
|
||||||
aud=_audience_deserialize(claims["aud"]),
|
aud=_audience_deserialize(claims["aud"]),
|
||||||
@@ -101,13 +76,8 @@ class Payload:
|
|||||||
exp=int(claims["exp"]),
|
exp=int(claims["exp"]),
|
||||||
jti=claims["jti"],
|
jti=claims["jti"],
|
||||||
exec_act=claims["exec_act"],
|
exec_act=claims["exec_act"],
|
||||||
par=claims.get("par") or [],
|
pred=claims.get("pred") or [],
|
||||||
pol=claims.get("pol", ""),
|
|
||||||
pol_decision=claims.get("pol_decision", ""),
|
|
||||||
sub=claims.get("sub", ""),
|
|
||||||
wid=claims.get("wid", ""),
|
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", ""),
|
inp_hash=claims.get("inp_hash", ""),
|
||||||
out_hash=claims.get("out_hash", ""),
|
out_hash=claims.get("out_hash", ""),
|
||||||
inp_classification=claims.get("inp_classification", ""),
|
inp_classification=claims.get("inp_classification", ""),
|
||||||
@@ -124,5 +94,13 @@ class Payload:
|
|||||||
return bool(self.ext.get("compensation_required"))
|
return bool(self.ext.get("compensation_required"))
|
||||||
|
|
||||||
def has_policy_claims(self) -> bool:
|
def has_policy_claims(self) -> bool:
|
||||||
"""True if both pol and pol_decision are present (optional pair per spec)."""
|
"""True if both pol and pol_decision are present in ext (per -01, moved to extension)."""
|
||||||
return bool(self.pol and self.pol_decision)
|
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", ""))
|
||||||
|
|||||||
@@ -9,14 +9,11 @@ from typing import Any
|
|||||||
|
|
||||||
EXT_MAX_SIZE = 4096
|
EXT_MAX_SIZE = 4096
|
||||||
EXT_MAX_DEPTH = 5
|
EXT_MAX_DEPTH = 5
|
||||||
DEFAULT_MAX_PAR_LENGTH = 100
|
DEFAULT_MAX_PRED_LENGTH = 100
|
||||||
|
|
||||||
_UUID_RE = re.compile(
|
_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}$"
|
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}$"
|
||||||
)
|
)
|
||||||
_ALLOWED_HASH_ALGS = frozenset(("sha-256", "sha-384", "sha-512"))
|
|
||||||
|
|
||||||
|
|
||||||
def _json_depth(obj: Any, depth: int = 0) -> int:
|
def _json_depth(obj: Any, depth: int = 0) -> int:
|
||||||
if depth > EXT_MAX_DEPTH:
|
if depth > EXT_MAX_DEPTH:
|
||||||
return depth
|
return depth
|
||||||
@@ -44,22 +41,22 @@ def valid_uuid(s: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def validate_hash_format(s: str) -> None:
|
def validate_hash_format(s: str) -> None:
|
||||||
"""Raise ValueError if s is non-empty and not algorithm:base64url (sha-256, sha-384, sha-512)."""
|
"""Raise ValueError if s is non-empty and not plain base64url per RFC 9449 / ECT spec.
|
||||||
|
|
||||||
|
The ECT spec (draft-nennemann-wimse-ect-01) and RFC 9449 specify
|
||||||
|
``base64url(SHA-256(data))`` — a plain base64url string without any
|
||||||
|
algorithm prefix. This matches how ACT handles hashes.
|
||||||
|
"""
|
||||||
if not s:
|
if not s:
|
||||||
return
|
return
|
||||||
idx = s.find(":")
|
# Reject strings containing non-base64url characters.
|
||||||
if idx <= 0:
|
# base64url alphabet: A-Z a-z 0-9 - _ (no padding '=' expected)
|
||||||
raise ValueError("ect: inp_hash/out_hash must be algorithm:base64url (e.g. sha-256:...)")
|
if not re.fullmatch(r"[A-Za-z0-9_-]+", s):
|
||||||
alg = s[:idx].lower()
|
raise ValueError("ect: inp_hash/out_hash must be plain base64url (no prefix)")
|
||||||
if alg not in _ALLOWED_HASH_ALGS:
|
# Verify it actually decodes.
|
||||||
raise ValueError("ect: inp_hash/out_hash must be algorithm:base64url (e.g. sha-256:...)")
|
pad = 4 - len(s) % 4
|
||||||
encoded = s[idx + 1:]
|
padded = s + "=" * pad if pad != 4 else s
|
||||||
if not encoded:
|
|
||||||
raise ValueError("ect: inp_hash/out_hash must be algorithm:base64url (e.g. sha-256:...)")
|
|
||||||
pad = 4 - len(encoded) % 4
|
|
||||||
if pad != 4:
|
|
||||||
encoded += "=" * pad
|
|
||||||
try:
|
try:
|
||||||
base64.urlsafe_b64decode(encoded)
|
base64.urlsafe_b64decode(padded)
|
||||||
except Exception:
|
except Exception:
|
||||||
raise ValueError("ect: inp_hash/out_hash must be algorithm:base64url (e.g. sha-256:...)") from None
|
raise ValueError("ect: inp_hash/out_hash must be plain base64url (no prefix)") from None
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from typing import Callable, Optional
|
|||||||
import jwt
|
import jwt
|
||||||
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicKey
|
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.dag import ECTStore, DAGConfig, validate_dag
|
||||||
from ect.validate import validate_ext, validate_hash_format, valid_uuid
|
from ect.validate import validate_ext, validate_hash_format, valid_uuid
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ class VerifyOptions:
|
|||||||
jti_seen: Optional[Callable[[str], bool]] = None
|
jti_seen: Optional[Callable[[str], bool]] = None
|
||||||
wit_subject: str = ""
|
wit_subject: str = ""
|
||||||
validate_uuids: bool = False
|
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
|
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:
|
def _verify_impl(compact: str, opts: VerifyOptions, set_log_jti: Callable[[str], None]) -> ParsedECT:
|
||||||
header = jwt.get_unverified_header(compact)
|
header = jwt.get_unverified_header(compact)
|
||||||
typ = header.get("typ") or ""
|
typ = header.get("typ") or ""
|
||||||
# Constant-time comparison for typ
|
# Constant-time comparison for typ; accept both preferred and legacy values
|
||||||
if not hmac.compare_digest(typ, ECT_TYPE):
|
if not hmac.compare_digest(typ, ECT_TYPE) and not hmac.compare_digest(typ, ECT_TYPE_LEGACY):
|
||||||
raise ValueError("ect: invalid typ parameter")
|
raise ValueError("ect: invalid typ parameter")
|
||||||
alg = header.get("alg")
|
alg = header.get("alg")
|
||||||
if alg in ("none", "HS256", "HS384", "HS512"):
|
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)
|
set_log_jti(payload.jti)
|
||||||
|
|
||||||
validate_ext(payload.ext)
|
validate_ext(payload.ext)
|
||||||
if opts.max_par_length > 0 and len(payload.par) > opts.max_par_length:
|
if opts.max_pred_length > 0 and len(payload.pred) > opts.max_pred_length:
|
||||||
raise ValueError("ect: par exceeds max length")
|
raise ValueError("ect: pred exceeds max length")
|
||||||
if opts.validate_uuids:
|
if opts.validate_uuids:
|
||||||
if not valid_uuid(payload.jti):
|
if not valid_uuid(payload.jti):
|
||||||
raise ValueError("ect: jti must be UUID format")
|
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:
|
if payload.iat > now + opts.iat_max_future_sec:
|
||||||
raise ValueError("ect: iat in the future")
|
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:
|
if not payload.jti or not payload.exec_act:
|
||||||
raise ValueError("ect: missing required claims (jti, exec_act, par)")
|
raise ValueError("ect: missing required claims (jti, exec_act, pred)")
|
||||||
if payload.par is None:
|
if payload.pred is None:
|
||||||
payload.par = []
|
payload.pred = []
|
||||||
# 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")
|
|
||||||
|
|
||||||
if opts.store is not None and opts.dag is not None:
|
if opts.store is not None and opts.dag is not None:
|
||||||
validate_dag(payload, opts.store, opts.dag)
|
validate_dag(payload, opts.store, opts.dag)
|
||||||
|
|||||||
@@ -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"}}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from ect import (
|
|||||||
CreateOptions,
|
CreateOptions,
|
||||||
verify,
|
verify,
|
||||||
VerifyOptions,
|
VerifyOptions,
|
||||||
POL_DECISION_APPROVED,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -27,9 +26,7 @@ def test_create_roundtrip():
|
|||||||
exp=now + 600,
|
exp=now + 600,
|
||||||
jti="e4f5a6b7-c8d9-0123-ef01-234567890abc",
|
jti="e4f5a6b7-c8d9-0123-ef01-234567890abc",
|
||||||
exec_act="review_spec",
|
exec_act="review_spec",
|
||||||
par=[],
|
pred=[],
|
||||||
pol="spec_review_policy_v2",
|
|
||||||
pol_decision=POL_DECISION_APPROVED,
|
|
||||||
)
|
)
|
||||||
compact = create(payload, key, CreateOptions(key_id="agent-a-key-1"))
|
compact = create(payload, key, CreateOptions(key_id="agent-a-key-1"))
|
||||||
assert compact
|
assert compact
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import time
|
|||||||
|
|
||||||
import pytest
|
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():
|
def test_default_create_options():
|
||||||
@@ -14,7 +14,7 @@ def test_default_create_options():
|
|||||||
|
|
||||||
def test_create_errors():
|
def test_create_errors():
|
||||||
key = generate_key()
|
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"):
|
with pytest.raises(ValueError, match="KeyID|required"):
|
||||||
create(p, key, CreateOptions(key_id=""))
|
create(p, key, CreateOptions(key_id=""))
|
||||||
with pytest.raises((ValueError, TypeError, AttributeError)):
|
with pytest.raises((ValueError, TypeError, AttributeError)):
|
||||||
@@ -26,7 +26,7 @@ def test_create_optional_pol():
|
|||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
p = Payload(
|
p = Payload(
|
||||||
iss="iss", aud=["a"], iat=now, exp=now + 3600,
|
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"))
|
compact = create(p, key, CreateOptions(key_id="kid"))
|
||||||
assert compact
|
assert compact
|
||||||
@@ -34,7 +34,7 @@ def test_create_optional_pol():
|
|||||||
|
|
||||||
def test_create_validation_errors():
|
def test_create_validation_errors():
|
||||||
key = generate_key()
|
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"):
|
with pytest.raises(ValueError, match="iss"):
|
||||||
create(Payload(**{**base, "iss": ""}), key, CreateOptions(key_id="k"))
|
create(Payload(**{**base, "iss": ""}), key, CreateOptions(key_id="k"))
|
||||||
with pytest.raises(ValueError, match="aud"):
|
with pytest.raises(ValueError, match="aud"):
|
||||||
@@ -43,16 +43,12 @@ def test_create_validation_errors():
|
|||||||
create(Payload(**{**base, "jti": ""}), key, CreateOptions(key_id="k"))
|
create(Payload(**{**base, "jti": ""}), key, CreateOptions(key_id="k"))
|
||||||
with pytest.raises(ValueError, match="exec_act"):
|
with pytest.raises(ValueError, match="exec_act"):
|
||||||
create(Payload(**{**base, "exec_act": ""}), key, CreateOptions(key_id="k"))
|
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():
|
def test_create_ext_compensation_reason_requires_required():
|
||||||
key = generate_key()
|
key = generate_key()
|
||||||
p = Payload(
|
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},
|
ext={"compensation_reason": "rollback", "compensation_required": False},
|
||||||
)
|
)
|
||||||
with pytest.raises(ValueError, match="compensation_required"):
|
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():
|
def test_create_zero_expiry_uses_default():
|
||||||
key = generate_key()
|
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))
|
compact = create(p, key, CreateOptions(key_id="k", default_expiry_sec=300))
|
||||||
assert compact
|
assert compact
|
||||||
# create() works on a copy; decode the token to verify defaults were applied
|
# 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():
|
def test_create_validate_uuids_rejects_non_uuid_jti():
|
||||||
key = generate_key()
|
key = generate_key()
|
||||||
now = int(time.time())
|
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"):
|
with pytest.raises(ValueError, match="jti must be UUID"):
|
||||||
create(p, key, CreateOptions(key_id="k", validate_uuids=True))
|
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()
|
key = generate_key()
|
||||||
now = int(time.time())
|
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"])
|
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="par exceeds max length"):
|
with pytest.raises(ValueError, match="pred exceeds max length"):
|
||||||
create(p, key, CreateOptions(key_id="k", max_par_length=1))
|
create(p, key, CreateOptions(key_id="k", max_pred_length=1))
|
||||||
|
|
||||||
|
|
||||||
def test_create_ext_size_rejected():
|
def test_create_ext_size_rejected():
|
||||||
@@ -91,7 +87,7 @@ def test_create_ext_size_rejected():
|
|||||||
key = generate_key()
|
key = generate_key()
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
p = Payload(
|
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)},
|
ext={"x": "y" * (EXT_MAX_SIZE - 5)},
|
||||||
)
|
)
|
||||||
with pytest.raises(ValueError, match="ext exceeds max size"):
|
with pytest.raises(ValueError, match="ext exceeds max size"):
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import time
|
|||||||
|
|
||||||
import pytest
|
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():
|
def test_validate_dag_root():
|
||||||
@@ -16,9 +16,7 @@ def test_validate_dag_root():
|
|||||||
exp=0,
|
exp=0,
|
||||||
jti="jti-001",
|
jti="jti-001",
|
||||||
exec_act="",
|
exec_act="",
|
||||||
par=[],
|
pred=[],
|
||||||
pol="",
|
|
||||||
pol_decision=POL_DECISION_APPROVED,
|
|
||||||
wid="wf-1",
|
wid="wf-1",
|
||||||
)
|
)
|
||||||
validate_dag(payload, store, default_dag_config())
|
validate_dag(payload, store, default_dag_config())
|
||||||
@@ -33,9 +31,7 @@ def test_validate_dag_duplicate_jti():
|
|||||||
exp=0,
|
exp=0,
|
||||||
jti="jti-001",
|
jti="jti-001",
|
||||||
exec_act="a",
|
exec_act="a",
|
||||||
par=[],
|
pred=[],
|
||||||
pol="p",
|
|
||||||
pol_decision=POL_DECISION_APPROVED,
|
|
||||||
wid="wf-1",
|
wid="wf-1",
|
||||||
)
|
)
|
||||||
store.append("dummy-jws", p)
|
store.append("dummy-jws", p)
|
||||||
@@ -46,16 +42,14 @@ def test_validate_dag_duplicate_jti():
|
|||||||
exp=0,
|
exp=0,
|
||||||
jti="jti-001",
|
jti="jti-001",
|
||||||
exec_act="",
|
exec_act="",
|
||||||
par=[],
|
pred=[],
|
||||||
pol="",
|
|
||||||
pol_decision=POL_DECISION_APPROVED,
|
|
||||||
wid="wf-1",
|
wid="wf-1",
|
||||||
)
|
)
|
||||||
with pytest.raises(ValueError, match="task ID.*already exists"):
|
with pytest.raises(ValueError, match="task ID.*already exists"):
|
||||||
validate_dag(payload, store, default_dag_config())
|
validate_dag(payload, store, default_dag_config())
|
||||||
|
|
||||||
|
|
||||||
def test_validate_dag_parent_exists():
|
def test_validate_dag_pred_exists():
|
||||||
store = MemoryLedger()
|
store = MemoryLedger()
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
p = Payload(
|
p = Payload(
|
||||||
@@ -65,9 +59,7 @@ def test_validate_dag_parent_exists():
|
|||||||
exp=now + 600,
|
exp=now + 600,
|
||||||
jti="jti-001",
|
jti="jti-001",
|
||||||
exec_act="a",
|
exec_act="a",
|
||||||
par=[],
|
pred=[],
|
||||||
pol="p",
|
|
||||||
pol_decision=POL_DECISION_APPROVED,
|
|
||||||
wid="wf-1",
|
wid="wf-1",
|
||||||
)
|
)
|
||||||
store.append("jws1", p)
|
store.append("jws1", p)
|
||||||
@@ -78,15 +70,13 @@ def test_validate_dag_parent_exists():
|
|||||||
exp=now + 600,
|
exp=now + 600,
|
||||||
jti="jti-002",
|
jti="jti-002",
|
||||||
exec_act="b",
|
exec_act="b",
|
||||||
par=["jti-001"],
|
pred=["jti-001"],
|
||||||
pol="p",
|
|
||||||
pol_decision=POL_DECISION_APPROVED,
|
|
||||||
wid="wf-1",
|
wid="wf-1",
|
||||||
)
|
)
|
||||||
validate_dag(payload, store, default_dag_config())
|
validate_dag(payload, store, default_dag_config())
|
||||||
|
|
||||||
|
|
||||||
def test_validate_dag_parent_not_found():
|
def test_validate_dag_pred_not_found():
|
||||||
store = MemoryLedger()
|
store = MemoryLedger()
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
payload = Payload(
|
payload = Payload(
|
||||||
@@ -96,26 +86,24 @@ def test_validate_dag_parent_not_found():
|
|||||||
exp=now + 600,
|
exp=now + 600,
|
||||||
jti="jti-002",
|
jti="jti-002",
|
||||||
exec_act="",
|
exec_act="",
|
||||||
par=["jti-missing"],
|
pred=["jti-missing"],
|
||||||
pol="",
|
|
||||||
pol_decision=POL_DECISION_APPROVED,
|
|
||||||
)
|
)
|
||||||
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())
|
validate_dag(payload, store, default_dag_config())
|
||||||
|
|
||||||
|
|
||||||
def test_validate_dag_parent_policy_rejected_requires_compensation():
|
def test_validate_dag_pred_policy_rejected_requires_compensation():
|
||||||
from ect import POL_DECISION_REJECTED
|
|
||||||
store = MemoryLedger()
|
store = MemoryLedger()
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
p = Payload(
|
p = Payload(
|
||||||
iss="x", aud=["y"], iat=now - 60, exp=now + 600,
|
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)
|
store.append("jws1", p)
|
||||||
payload = Payload(
|
payload = Payload(
|
||||||
iss="", aud=[], iat=now, exp=now + 600,
|
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"):
|
with pytest.raises(ValueError, match="compensation"):
|
||||||
validate_dag(payload, store, default_dag_config())
|
validate_dag(payload, store, default_dag_config())
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import time
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from ect import Payload, MemoryLedger, ErrTaskIDExists, POL_DECISION_APPROVED
|
from ect import Payload, MemoryLedger, ErrTaskIDExists
|
||||||
|
|
||||||
|
|
||||||
def test_ledger_append_and_get():
|
def test_ledger_append_and_get():
|
||||||
m = MemoryLedger()
|
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)
|
seq = m.append("jws1", p)
|
||||||
assert seq == 1
|
assert seq == 1
|
||||||
assert m.get_by_tid("j1").jti == "j1"
|
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():
|
def test_ledger_err_task_id_exists():
|
||||||
m = MemoryLedger()
|
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)
|
m.append("jws1", p)
|
||||||
with pytest.raises(ErrTaskIDExists):
|
with pytest.raises(ErrTaskIDExists):
|
||||||
m.append("jws2", p)
|
m.append("jws2", p)
|
||||||
@@ -25,7 +25,7 @@ def test_ledger_err_task_id_exists():
|
|||||||
|
|
||||||
def test_ledger_contains_wid():
|
def test_ledger_contains_wid():
|
||||||
m = MemoryLedger()
|
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)
|
m.append("jws", p)
|
||||||
assert m.contains("j1", "") is True
|
assert m.contains("j1", "") is True
|
||||||
assert m.contains("j1", "wf1") is True
|
assert m.contains("j1", "wf1") is True
|
||||||
|
|||||||
@@ -2,62 +2,63 @@
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from ect import Payload, POL_DECISION_APPROVED
|
from ect import Payload
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def test_payload_contains_audience():
|
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("a") is True
|
||||||
assert p.contains_audience("c") is False
|
assert p.contains_audience("c") is False
|
||||||
|
|
||||||
|
|
||||||
def test_payload_compensation_required():
|
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
|
assert p.compensation_required() is False
|
||||||
p.ext = {"compensation_required": True}
|
p.ext = {"compensation_required": True}
|
||||||
assert p.compensation_required() is True
|
assert p.compensation_required() is True
|
||||||
|
|
||||||
|
|
||||||
def test_payload_has_policy_claims():
|
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
|
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
|
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():
|
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()
|
claims = p.to_claims()
|
||||||
assert claims["wid"] == "wf"
|
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():
|
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)
|
p = Payload.from_claims(claims)
|
||||||
assert p.aud == ["single"]
|
assert p.aud == ["single"]
|
||||||
|
|
||||||
|
|
||||||
def test_payload_to_claims_all_optional():
|
def test_payload_to_claims_all_optional():
|
||||||
p = Payload(
|
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=[],
|
||||||
sub="s", wid="w", pol="p", pol_decision="approved", pol_enforcer="e",
|
wid="w", inp_hash="h", out_hash="o", inp_classification="c",
|
||||||
pol_timestamp=1, inp_hash="h", out_hash="o", inp_classification="c",
|
ext={"pol": "p", "pol_decision": "approved"},
|
||||||
)
|
)
|
||||||
claims = p.to_claims()
|
claims = p.to_claims()
|
||||||
assert claims["sub"] == "s"
|
|
||||||
assert claims["wid"] == "w"
|
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["inp_hash"] == "h"
|
||||||
assert claims["out_hash"] == "o"
|
assert claims["out_hash"] == "o"
|
||||||
assert claims["inp_classification"] == "c"
|
assert claims["inp_classification"] == "c"
|
||||||
|
assert claims["ect_ext"]["pol"] == "p"
|
||||||
|
assert claims["ect_ext"]["pol_decision"] == "approved"
|
||||||
|
|||||||
@@ -47,17 +47,18 @@ def test_validate_hash_format_empty():
|
|||||||
|
|
||||||
|
|
||||||
def test_validate_hash_format_ok():
|
def test_validate_hash_format_ok():
|
||||||
# sha-256:base64url (minimal valid)
|
# Plain base64url per RFC 9449 / ECT spec (no algorithm prefix)
|
||||||
validate_hash_format("sha-256:YQ")
|
validate_hash_format("YQ")
|
||||||
validate_hash_format("sha-384:YQ")
|
validate_hash_format("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")
|
||||||
validate_hash_format("sha-512:YQ")
|
validate_hash_format("abc123-_XYZ")
|
||||||
|
|
||||||
|
|
||||||
def test_validate_hash_format_bad():
|
def test_validate_hash_format_bad():
|
||||||
with pytest.raises(ValueError, match="algorithm:base64url|inp_hash"):
|
# Colon is not valid base64url — rejects old prefixed format
|
||||||
validate_hash_format("md5:abc")
|
with pytest.raises(ValueError, match="plain base64url"):
|
||||||
with pytest.raises(ValueError, match="algorithm:base64url|inp_hash"):
|
validate_hash_format("sha-256:YQ")
|
||||||
validate_hash_format("no-colon")
|
with pytest.raises(ValueError, match="plain base64url"):
|
||||||
# Invalid base64 that triggers decode error (e.g. binary)
|
validate_hash_format("not valid!!")
|
||||||
with pytest.raises(ValueError, match="algorithm:base64url|inp_hash"):
|
# Null byte in payload
|
||||||
validate_hash_format("sha-256:YQ\x00") # null in payload
|
with pytest.raises(ValueError, match="plain base64url"):
|
||||||
|
validate_hash_format("YQ\x00")
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from ect import (
|
|||||||
verify,
|
verify,
|
||||||
VerifyOptions,
|
VerifyOptions,
|
||||||
default_verify_options,
|
default_verify_options,
|
||||||
POL_DECISION_APPROVED,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -22,7 +21,7 @@ def test_parse():
|
|||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
p = Payload(
|
p = Payload(
|
||||||
iss="iss", aud=["a"], iat=now, exp=now + 3600,
|
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"))
|
compact = create(p, key, CreateOptions(key_id="kid"))
|
||||||
parsed = parse(compact)
|
parsed = parse(compact)
|
||||||
@@ -41,7 +40,7 @@ def test_verify_expired():
|
|||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
p = Payload(
|
p = Payload(
|
||||||
iss="iss", aud=["v"], iat=now - 3600, exp=now - 60,
|
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"))
|
compact = create(p, key, CreateOptions(key_id="kid"))
|
||||||
resolver = lambda kid: key.public_key() if kid == "kid" else None
|
resolver = lambda kid: key.public_key() if kid == "kid" else None
|
||||||
@@ -54,7 +53,7 @@ def test_verify_replay():
|
|||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
p = Payload(
|
p = Payload(
|
||||||
iss="iss", aud=["v"], iat=now, exp=now + 3600,
|
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"))
|
compact = create(p, key, CreateOptions(key_id="kid"))
|
||||||
resolver = lambda kid: key.public_key() if kid == "kid" else None
|
resolver = lambda kid: key.public_key() if kid == "kid" else None
|
||||||
@@ -76,7 +75,7 @@ def test_verify_audience_mismatch():
|
|||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
p = Payload(
|
p = Payload(
|
||||||
iss="iss", aud=["other"], iat=now, exp=now + 3600,
|
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"))
|
compact = create(p, key, CreateOptions(key_id="kid"))
|
||||||
resolver = lambda kid: key.public_key() if kid == "kid" else None
|
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())
|
now = int(time.time())
|
||||||
p = Payload(
|
p = Payload(
|
||||||
iss="wrong-iss", aud=["v"], iat=now, exp=now + 3600,
|
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"))
|
compact = create(p, key, CreateOptions(key_id="kid"))
|
||||||
resolver = lambda kid: key.public_key() if kid == "kid" else None
|
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())
|
now = int(time.time())
|
||||||
p = Payload(
|
p = Payload(
|
||||||
iss="iss", aud=["v"], iat=now - 2000, exp=now + 3600,
|
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"))
|
compact = create(p, key, CreateOptions(key_id="kid"))
|
||||||
resolver = lambda kid: key.public_key() if kid == "kid" else None
|
resolver = lambda kid: key.public_key() if kid == "kid" else None
|
||||||
@@ -119,7 +118,7 @@ def test_verify_unknown_key():
|
|||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
p = Payload(
|
p = Payload(
|
||||||
iss="iss", aud=["v"], iat=now, exp=now + 3600,
|
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"))
|
compact = create(p, key, CreateOptions(key_id="kid"))
|
||||||
resolver = lambda kid: None # unknown key
|
resolver = lambda kid: None # unknown key
|
||||||
@@ -132,7 +131,7 @@ def test_verify_resolve_key_required():
|
|||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
p = Payload(
|
p = Payload(
|
||||||
iss="iss", aud=["v"], iat=now, exp=now + 3600,
|
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"))
|
compact = create(p, key, CreateOptions(key_id="kid"))
|
||||||
with pytest.raises(ValueError, match="ResolveKey"):
|
with pytest.raises(ValueError, match="ResolveKey"):
|
||||||
@@ -146,7 +145,7 @@ def test_verify_with_dag():
|
|||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
root = Payload(
|
root = Payload(
|
||||||
iss="iss", aud=["v"], iat=now, exp=now + 3600,
|
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"))
|
compact_root = create(root, key, CreateOptions(key_id="kid"))
|
||||||
resolver = lambda kid: key.public_key() if kid == "kid" else None
|
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)
|
ledger.append(compact_root, parsed.payload)
|
||||||
child = Payload(
|
child = Payload(
|
||||||
iss="iss", aud=["v"], iat=now + 1, exp=now + 3600,
|
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"))
|
compact_child = create(child, key, CreateOptions(key_id="kid"))
|
||||||
parsed2 = verify(compact_child, opts)
|
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)."""
|
"""Observability: on_verify_attempt is called with jti and error (or None)."""
|
||||||
key = generate_key()
|
key = generate_key()
|
||||||
now = int(time.time())
|
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"))
|
compact = create(p, key, CreateOptions(key_id="kid"))
|
||||||
resolver = lambda k: key.public_key() if k == "kid" else None
|
resolver = lambda k: key.public_key() if k == "kid" else None
|
||||||
seen = []
|
seen = []
|
||||||
@@ -183,7 +182,7 @@ def test_on_verify_attempt_callback():
|
|||||||
def test_on_verify_attempt_called_on_failure():
|
def test_on_verify_attempt_called_on_failure():
|
||||||
key = generate_key()
|
key = generate_key()
|
||||||
now = int(time.time())
|
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"))
|
compact = create(p, key, CreateOptions(key_id="kid"))
|
||||||
resolver = lambda k: key.public_key() if k == "kid" else None
|
resolver = lambda k: key.public_key() if k == "kid" else None
|
||||||
seen = []
|
seen = []
|
||||||
@@ -193,5 +192,3 @@ def test_on_verify_attempt_called_on_failure():
|
|||||||
assert len(seen) == 1
|
assert len(seen) == 1
|
||||||
assert seen[0][0] == "jti-fail"
|
assert seen[0][0] == "jti-fail"
|
||||||
assert seen[0][1] is not None
|
assert seen[0][1] is not None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user