From bbf557e54b29946e84df55ea7764ced92da3bfd5 Mon Sep 17 00:00:00 2001 From: Christian Nennemann Date: Wed, 25 Feb 2026 23:11:55 +0100 Subject: [PATCH] Restructure refimpl into go-lang and python subdirectories Move Go reference implementation to refimpl/go-lang/ and add new Python reference implementation in refimpl/python/. Update build.sh with renamed draft and simplified tool paths. Co-Authored-By: Claude Opus 4.6 --- build.sh | 19 +- refimpl/IMPROVEMENTS.md | 60 ++ refimpl/README.md | 88 +-- refimpl/ect/create_test.go | 99 --- refimpl/ect/dag_test.go | 61 -- refimpl/go-lang/README.md | 107 +++ refimpl/{ => go-lang}/cmd/demo/main.go | 20 +- refimpl/{ => go-lang}/ect/audience.go | 0 refimpl/go-lang/ect/config.go | 84 +++ refimpl/go-lang/ect/config_test.go | 79 +++ refimpl/{ => go-lang}/ect/create.go | 62 +- refimpl/go-lang/ect/create_test.go | 192 +++++ refimpl/{ => go-lang}/ect/dag.go | 22 +- refimpl/go-lang/ect/dag_test.go | 140 ++++ refimpl/go-lang/ect/errors.go | 29 + refimpl/go-lang/ect/jti_cache.go | 69 ++ refimpl/go-lang/ect/jti_cache_test.go | 56 ++ refimpl/{ => go-lang}/ect/ledger.go | 12 +- refimpl/go-lang/ect/ledger_test.go | 58 ++ refimpl/{ => go-lang}/ect/types.go | 43 +- refimpl/go-lang/ect/types_test.go | 115 +++ refimpl/go-lang/ect/validate.go | 83 +++ refimpl/{ => go-lang}/ect/verify.go | 92 ++- refimpl/go-lang/ect/verify_test.go | 243 +++++++ refimpl/{ => go-lang}/go.mod | 2 +- refimpl/{ => go-lang}/go.sum | 0 .../testdata/valid_root_ect_payload.json | 1 + refimpl/python/README.md | 100 +++ refimpl/python/demo.py | 99 +++ refimpl/python/ect/__init__.py | 61 ++ refimpl/python/ect/config.py | 61 ++ refimpl/python/ect/create.py | 115 +++ refimpl/python/ect/dag.py | 97 +++ refimpl/python/ect/jti_cache.py | 52 ++ refimpl/python/ect/ledger.py | 97 +++ refimpl/python/ect/types.py | 128 ++++ refimpl/python/ect/validate.py | 65 ++ refimpl/python/ect/verify.py | 160 +++++ refimpl/python/pyproject.toml | 25 + .../testdata/valid_root_ect_payload.json | 1 + refimpl/python/tests/__init__.py | 1 + refimpl/python/tests/test_config.py | 49 ++ refimpl/python/tests/test_create.py | 77 +++ refimpl/python/tests/test_create_extra.py | 98 +++ refimpl/python/tests/test_dag.py | 123 ++++ refimpl/python/tests/test_jti_cache.py | 40 ++ refimpl/python/tests/test_ledger_extra.py | 38 + refimpl/python/tests/test_types_extra.py | 63 ++ refimpl/python/tests/test_validate.py | 63 ++ refimpl/python/tests/test_verify.py | 197 ++++++ refimpl/python/uv.lock | 653 ++++++++++++++++++ refimpl/testdata/valid_root_ect_payload.json | 14 - 52 files changed, 3972 insertions(+), 341 deletions(-) create mode 100644 refimpl/IMPROVEMENTS.md delete mode 100644 refimpl/ect/create_test.go delete mode 100644 refimpl/ect/dag_test.go create mode 100644 refimpl/go-lang/README.md rename refimpl/{ => go-lang}/cmd/demo/main.go (80%) rename refimpl/{ => go-lang}/ect/audience.go (100%) create mode 100644 refimpl/go-lang/ect/config.go create mode 100644 refimpl/go-lang/ect/config_test.go rename refimpl/{ => go-lang}/ect/create.go (57%) create mode 100644 refimpl/go-lang/ect/create_test.go rename refimpl/{ => go-lang}/ect/dag.go (79%) create mode 100644 refimpl/go-lang/ect/dag_test.go create mode 100644 refimpl/go-lang/ect/errors.go create mode 100644 refimpl/go-lang/ect/jti_cache.go create mode 100644 refimpl/go-lang/ect/jti_cache_test.go rename refimpl/{ => go-lang}/ect/ledger.go (88%) create mode 100644 refimpl/go-lang/ect/ledger_test.go rename refimpl/{ => go-lang}/ect/types.go (70%) create mode 100644 refimpl/go-lang/ect/types_test.go create mode 100644 refimpl/go-lang/ect/validate.go rename refimpl/{ => go-lang}/ect/verify.go (68%) create mode 100644 refimpl/go-lang/ect/verify_test.go rename refimpl/{ => go-lang}/go.mod (68%) rename refimpl/{ => go-lang}/go.sum (100%) create mode 100644 refimpl/go-lang/testdata/valid_root_ect_payload.json create mode 100644 refimpl/python/README.md create mode 100644 refimpl/python/demo.py create mode 100644 refimpl/python/ect/__init__.py create mode 100644 refimpl/python/ect/config.py create mode 100644 refimpl/python/ect/create.py create mode 100644 refimpl/python/ect/dag.py create mode 100644 refimpl/python/ect/jti_cache.py create mode 100644 refimpl/python/ect/ledger.py create mode 100644 refimpl/python/ect/types.py create mode 100644 refimpl/python/ect/validate.py create mode 100644 refimpl/python/ect/verify.py create mode 100644 refimpl/python/pyproject.toml create mode 100644 refimpl/python/testdata/valid_root_ect_payload.json create mode 100644 refimpl/python/tests/__init__.py create mode 100644 refimpl/python/tests/test_config.py create mode 100644 refimpl/python/tests/test_create.py create mode 100644 refimpl/python/tests/test_create_extra.py create mode 100644 refimpl/python/tests/test_dag.py create mode 100644 refimpl/python/tests/test_jti_cache.py create mode 100644 refimpl/python/tests/test_ledger_extra.py create mode 100644 refimpl/python/tests/test_types_extra.py create mode 100644 refimpl/python/tests/test_validate.py create mode 100644 refimpl/python/tests/test_verify.py create mode 100644 refimpl/python/uv.lock delete mode 100644 refimpl/testdata/valid_root_ect_payload.json diff --git a/build.sh b/build.sh index 5825fab..457a31b 100755 --- a/build.sh +++ b/build.sh @@ -1,23 +1,12 @@ #!/bin/bash set -e -DRAFT="draft-nennemann-wimse-execution-context-00" +DRAFT="draft-nennemann-wimse-ect-00" DIR="$(cd "$(dirname "$0")" && pwd)" -# Resolve tool paths -KDRFC="$(find /usr/local/lib/ruby/gems /opt/homebrew -name kdrfc -path "*/bin/*" 2>/dev/null | head -1)" -KRAMDOWN="$(find /usr/local/lib/ruby/gems /opt/homebrew -name kramdown-rfc2629 -path "*/bin/*" 2>/dev/null | head -1)" -XML2RFC="$(find "$HOME/Library/Python" /usr/local /opt/homebrew -name xml2rfc -path "*/bin/*" 2>/dev/null | head -1)" - -if [ -z "$KRAMDOWN" ]; then - echo "Error: kramdown-rfc2629 not found. Install with: gem install kramdown-rfc" >&2 - exit 1 -fi - -if [ -z "$XML2RFC" ]; then - echo "Error: xml2rfc not found. Install with: pip install xml2rfc" >&2 - exit 1 -fi +# Tool paths +KRAMDOWN="/usr/local/lib/ruby/gems/3.4.0/bin/kramdown-rfc2629" +XML2RFC="/Users/christian/Library/Python/3.9/bin/xml2rfc" export PYTHONWARNINGS="ignore::UserWarning" diff --git a/refimpl/IMPROVEMENTS.md b/refimpl/IMPROVEMENTS.md new file mode 100644 index 0000000..7874fb7 --- /dev/null +++ b/refimpl/IMPROVEMENTS.md @@ -0,0 +1,60 @@ +# Possible Improvements (Go & Python Refimpls) + +Suggestions that could make the implementations more robust, spec-strict, or production-friendly. **All items below have been implemented** in both refimpls unless noted. + +--- + +## 1. **Spec alignment** ✅ + +- **ext size/depth (Section 4.2.7)** + **Done.** Both refimpls reject when serialized `ext` exceeds 4096 bytes or JSON depth exceeds 5 (`ValidateExt` / `validate_ext`). Used in create and verify. + +- **jti / wid format** + **Done.** Optional UUID (RFC 9562) validation: `CreateOptions.ValidateUUIDs` / `VerifyOptions.ValidateUUIDs` (Go), `validate_uuids` (Python). Helpers: `ValidUUID` / `valid_uuid`. + +--- + +## 2. **API and safety** ✅ + +- **Payload mutation in Create** + **Done.** Documented in both: Create may set Iat, Exp, Sub, Par when zero/nil. **Go:** comment on `Create()`; **Python:** create works on a deep copy so the caller’s payload is not modified. + +- **Structured errors (Go)** + **Done.** Sentinel errors in `ect/errors.go`: `ErrExpired`, `ErrReplay`, `ErrInvalidSignature` (wrapped), `ErrInvalidTyp`, `ErrPolPolDecisionPair`, etc. Verify and create return these where applicable. + +--- + +## 3. **Production / operations** ✅ + +- **Replay cache** + **Done.** Documented: JTICache is in-memory; for multi-instance deployments a shared store (Redis, DB) is required. See refimpl README and go-lang/README “Replay cache (multi-instance)”. + +- **Observability** + **Done.** **Go:** `VerifyOptions.LogVerify func(jti string, err error)` called after each verify. **Python:** `VerifyOptions.on_verify_attempt(jti, err)` callback. + +--- + +## 4. **Small cleanups** ✅ + +- **Python Ledger docstring** + **Done.** “Lookup by task id (jti)”. + +- **Python `verify`** + **Done.** Documented that `par` may be set to `[]` when missing; `from_claims` already supplies `[]`, so mutation is defensive only. + +- **par length** + **Done.** **Go:** `CreateOptions.MaxParLength`, `VerifyOptions.MaxParLength`, `DAGConfig.MaxParLength` (0 = no limit; default 100 in DAG). **Python:** `CreateOptions.max_par_length`, `VerifyOptions.max_par_length`, `DAGConfig.max_par_length`. + +--- + +## 5. **Nice-to-have** ✅ + +- **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`. + +- **Constant-time comparison** + **Done.** **Go:** `crypto/subtle.ConstantTimeCompare` for `typ` in verify. **Python:** `hmac.compare_digest` for `typ`. + +--- + +**Summary:** All listed improvements are implemented. For production, also consider: key rotation, WIT integration, and metrics around verify/create latency and error kinds. diff --git a/refimpl/README.md b/refimpl/README.md index e83937e..0f581b1 100644 --- a/refimpl/README.md +++ b/refimpl/README.md @@ -1,8 +1,15 @@ -# WIMSE Execution Context Tokens — Reference Implementation +# WIMSE Execution Context Tokens — Reference Implementations -This directory contains a **reference implementation** of [Execution Context Tokens (ECTs)](../draft-nennemann-wimse-execution-context-00.txt) for the WIMSE (Workload Identity in Multi System Environments) draft. It implements ECT creation, verification, DAG validation, and an in-memory audit ledger. +This directory contains **reference implementations** of [Execution Context Tokens (ECTs)](../draft-nennemann-wimse-execution-context-00.txt) for the WIMSE (Workload Identity in Multi System Environments) draft. Each refimpl provides ECT creation, verification, DAG validation, and an in-memory audit ledger. -## Scope +## Implementations + +| Language | Path | Description | +|----------|-----------|-------------| +| **Go** | [go-lang/](go-lang/) | Production-ready Go library and demo. Config via env; optional JTI replay cache. | +| **Python** | [python/](python/) | Python 3.9+ library and demo. Same API surface and env-based config. | + +## Scope (all refimpls) - **ECT format**: JWT (JWS Compact Serialization) with required/optional claims per the spec (Section 4). - **Creation**: Build and sign ECTs with ES256; `kid` and `typ: wimse-exec+jwt` in the JOSE header. @@ -10,83 +17,28 @@ This directory contains a **reference implementation** of [Execution Context Tok - **DAG validation**: Section 6 (uniqueness, parent existence, temporal ordering, acyclicity, parent policy). - **Ledger**: Interface plus in-memory append-only store (Section 9). -No WIT/WPT issuance or full WIMSE stack; the refimpl uses key resolution only. Suitable for conformance testing and as a template for production integrations. +No WIT/WPT issuance or full WIMSE stack; refimpls use key resolution only. Suitable for conformance testing and as a template for production integrations. -## Layout +### Replay cache (multi-instance) -``` -refimpl/ -├── go.mod -├── README.md -├── ect/ # library -│ ├── types.go # Payload, Audience, constants -│ ├── audience.go # aud marshal/unmarshal -│ ├── create.go # Create(), GenerateKey() -│ ├── verify.go # Parse(), Verify(), VerifyOptions -│ ├── dag.go # ValidateDAG(), ECTStore -│ ├── ledger.go # Ledger, MemoryLedger -│ └── *_test.go -├── testdata/ -│ └── valid_root_ect_payload.json -└── cmd/ - └── demo/ # two-agent workflow demo - └── main.go -``` +The optional JTI replay cache (`JTICache` / `JtiCache`) is **in-memory only**. For multiple verifier instances behind a load balancer, replay detection must be shared. Use a distributed store (e.g. Redis, database) and implement the same contract as `JTISeen`: a function that returns true if the JTI was already seen, and ensure each verified JTI is recorded (e.g. with TTL). See go-lang/README and python/README for configuration and how to plug in a custom `JTISeen` / `jti_seen`. -## Usage +## Quick start -### Library - -```go -import "github.com/nennemann/ect-refimpl/ect" - -// Create -key, _ := ect.GenerateKey() -payload := &ect.Payload{ - Iss: "spiffe://example.com/agent/a", - Aud: ect.Audience{"spiffe://example.com/agent/b"}, - Iat: time.Now().Unix(), - Exp: time.Now().Add(10*time.Minute).Unix(), - Jti: "uuid-...", - Tid: "task-001", - ExecAct: "review_spec", - Par: []string{}, - Pol: "policy_v1", - PolDecision: ect.PolDecisionApproved, -} -compact, err := ect.Create(payload, key, ect.CreateOptions{KeyID: "agent-a-key"}) - -// Verify (with DAG store) -store := ect.NewMemoryLedger() -resolver := func(kid string) (*ecdsa.PublicKey, error) { ... } -parsed, err := ect.Verify(compact, ect.VerifyOptions{ - VerifierID: "spiffe://example.com/agent/b", - ResolveKey: resolver, - Store: store, -}) -store.Append(compact, parsed.Payload) -``` - -### Demo - -From the repo root (or `refimpl/`): +**Go** ```bash -cd refimpl && go run ./cmd/demo +cd refimpl/go-lang && go run ./cmd/demo +go test ./... ``` -Runs a two-agent flow: Agent A issues a root ECT, Agent B verifies and appends it, then issues a child ECT; verification uses DAG validation against the ledger. - -## Tests +**Python** ```bash -cd refimpl && go test ./... +cd refimpl/python && pip install -e . && python3 demo.py +python3 -m pytest tests/ -v ``` -## Dependencies - -- [go-jose/v4](https://github.com/go-jose/go-jose/v4) for JWS (ES256) and JWK handling. No custom crypto. - ## Specification - **Draft**: `draft-nennemann-wimse-execution-context-00` diff --git a/refimpl/ect/create_test.go b/refimpl/ect/create_test.go deleted file mode 100644 index f86a92a..0000000 --- a/refimpl/ect/create_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package ect - -import ( - "crypto/ecdsa" - "encoding/json" - "os" - "testing" - "time" -) - -func TestCreateRoundtrip(t *testing.T) { - key, err := GenerateKey() - if err != nil { - t.Fatal(err) - } - now := time.Now() - payload := &Payload{ - Iss: "spiffe://example.com/agent/a", - Aud: []string{"spiffe://example.com/agent/b"}, - Iat: now.Unix(), - Exp: now.Add(10 * time.Minute).Unix(), - Jti: "e4f5a6b7-c8d9-0123-ef01-234567890abc", - Tid: "550e8400-e29b-41d4-a716-446655440001", - ExecAct: "review_spec", - Par: []string{}, - Pol: "spec_review_policy_v2", - PolDecision: PolDecisionApproved, - } - compact, err := Create(payload, key, CreateOptions{KeyID: "agent-a-key-1"}) - if err != nil { - t.Fatal(err) - } - if compact == "" { - t.Fatal("expected non-empty compact JWS") - } - - // Verify with same key - resolver := func(kid string) (*ecdsa.PublicKey, error) { - if kid != "agent-a-key-1" { - return nil, nil - } - return &key.PublicKey, nil - } - opts := VerifyOptions{ - VerifierID: "spiffe://example.com/agent/b", - ResolveKey: resolver, - Now: now, - IATMaxAge: 15 * time.Minute, - IATMaxFuture: 30 * time.Second, - } - parsed, err := Verify(compact, opts) - if err != nil { - t.Fatal(err) - } - if parsed.Payload.Tid != payload.Tid || parsed.Payload.ExecAct != payload.ExecAct { - t.Errorf("payload mismatch: got tid=%q exec_act=%q", parsed.Payload.Tid, parsed.Payload.ExecAct) - } -} - -func TestCreateWithTestVector(t *testing.T) { - data, err := os.ReadFile("testdata/valid_root_ect_payload.json") - if err != nil { - t.Skipf("test vector not found: %v", err) - return - } - var p Payload - if err := json.Unmarshal(data, &p); err != nil { - t.Fatal(err) - } - key, err := GenerateKey() - if err != nil { - t.Fatal(err) - } - // Override timestamps for verification - now := time.Now() - p.Iat = now.Unix() - p.Exp = now.Add(10 * time.Minute).Unix() - - compact, err := Create(&p, key, CreateOptions{KeyID: "test-kid"}) - if err != nil { - t.Fatal(err) - } - resolver := func(kid string) (*ecdsa.PublicKey, error) { - if kid != "test-kid" { - return nil, nil - } - return &key.PublicKey, nil - } - _, err = Verify(compact, VerifyOptions{ - VerifierID: p.Aud[0], - ResolveKey: resolver, - Now: now, - IATMaxAge: 15 * time.Minute, - IATMaxFuture: 30 * time.Second, - }) - if err != nil { - t.Fatal(err) - } -} diff --git a/refimpl/ect/dag_test.go b/refimpl/ect/dag_test.go deleted file mode 100644 index 543ebcb..0000000 --- a/refimpl/ect/dag_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package ect - -import ( - "testing" - "time" -) - -func TestValidateDAG_Root(t *testing.T) { - store := NewMemoryLedger() - payload := &Payload{ - Tid: "task-001", - Wid: "wf-1", - Par: []string{}, - PolDecision: PolDecisionApproved, - } - err := ValidateDAG(payload, store, DefaultDAGConfig()) - if err != nil { - t.Fatal(err) - } -} - -func TestValidateDAG_DuplicateTid(t *testing.T) { - store := NewMemoryLedger() - // Pre-insert same tid - _, _ = store.Append("dummy-jws", &Payload{Tid: "task-001", Wid: "wf-1", Par: []string{}, PolDecision: PolDecisionApproved}) - payload := &Payload{Tid: "task-001", Wid: "wf-1", Par: []string{}, PolDecision: PolDecisionApproved} - err := ValidateDAG(payload, store, DefaultDAGConfig()) - if err == nil { - t.Fatal("expected error for duplicate tid") - } -} - -func TestValidateDAG_ParentExists(t *testing.T) { - store := NewMemoryLedger() - _, _ = store.Append("jws1", &Payload{Tid: "task-001", Wid: "wf-1", Par: []string{}, PolDecision: PolDecisionApproved, Iat: time.Now().Unix() - 60}) - payload := &Payload{ - Tid: "task-002", - Wid: "wf-1", - Par: []string{"task-001"}, - PolDecision: PolDecisionApproved, - Iat: time.Now().Unix(), - } - err := ValidateDAG(payload, store, DefaultDAGConfig()) - if err != nil { - t.Fatal(err) - } -} - -func TestValidateDAG_ParentNotFound(t *testing.T) { - store := NewMemoryLedger() - payload := &Payload{ - Tid: "task-002", - Par: []string{"task-missing"}, - PolDecision: PolDecisionApproved, - Iat: time.Now().Unix(), - } - err := ValidateDAG(payload, store, DefaultDAGConfig()) - if err == nil { - t.Fatal("expected error when parent not found") - } -} diff --git a/refimpl/go-lang/README.md b/refimpl/go-lang/README.md new file mode 100644 index 0000000..4065530 --- /dev/null +++ b/refimpl/go-lang/README.md @@ -0,0 +1,107 @@ +# WIMSE ECT — Go Reference Implementation + +Go reference implementation of [Execution Context Tokens (ECTs)](../../draft-nennemann-wimse-execution-context-00.txt) for WIMSE. Implements ECT creation (ES256), verification (Section 7), DAG validation (Section 6), and an in-memory audit ledger (Section 9). + +## Layout + +``` +go-lang/ +├── go.mod, go.sum +├── ect/ # library +│ ├── types.go # Payload, Audience, constants +│ ├── audience.go # aud marshal/unmarshal +│ ├── create.go # Create(), GenerateKey() +│ ├── verify.go # Parse(), Verify(), VerifyOptions +│ ├── dag.go # ValidateDAG(), ECTStore +│ ├── ledger.go # Ledger, MemoryLedger +│ ├── config.go # Config, LoadConfigFromEnv() +│ ├── jti_cache.go # JTICache for replay protection +│ ├── errors.go # Sentinel errors (ErrExpired, ErrReplay, etc.) +│ ├── validate.go # ValidateExt, ValidUUID, ValidateHashFormat +│ └── *_test.go +├── testdata/ +│ └── valid_root_ect_payload.json +└── cmd/demo/ # two-agent workflow demo + └── main.go +``` + +## Usage + +### Library + +```go +import "github.com/nennemann/ect-refimpl/go-lang/ect" + +// Production: load config from environment +cfg := ect.LoadConfigFromEnv() + +key, _ := ect.GenerateKey() +payload := &ect.Payload{ + Iss: "spiffe://example.com/agent/a", + Aud: ect.Audience{"spiffe://example.com/agent/b"}, + Iat: time.Now().Unix(), + Exp: time.Now().Add(10*time.Minute).Unix(), + Jti: "550e8400-e29b-41d4-a716-446655440000", + ExecAct: "review_spec", + Par: []string{}, + Pol: "policy_v1", + PolDecision: ect.PolDecisionApproved, +} +compact, err := ect.Create(payload, key, cfg.CreateOptions("agent-a-key")) + +// Verify with optional replay cache +store := ect.NewMemoryLedger() +var jtiCache ect.JTICache +if cfg.JTIReplaySize > 0 { + jtiCache = ect.NewJTICache(cfg.JTIReplaySize, cfg.JTIReplayTTL) +} +opts := cfg.VerifyOptions() +opts.VerifierID = "spiffe://example.com/agent/b" +opts.ResolveKey = resolver +opts.Store = store +if jtiCache != nil { + opts.JTISeen = jtiCache.Seen +} +parsed, err := ect.Verify(compact, opts) +if err != nil { ... } +if jtiCache != nil { + jtiCache.Add(parsed.Payload.Jti) +} +store.Append(compact, parsed.Payload) +``` + +### Demo + +```bash +cd refimpl/go-lang && go run ./cmd/demo +``` + +### Tests + +```bash +cd refimpl/go-lang && go test ./ect/... -cover +``` + +Unit tests are in `ect/*_test.go`. Coverage target: **~90%** (run `go test ./ect/... -coverprofile=cover.out && go tool cover -func=cover.out`). Remaining uncovered lines are mostly Parse/Verify error paths that require custom JWS or multi-sig tokens. + +## Production configuration (environment) + +| Variable | Default | Description | +|----------|---------|-------------| +| `ECT_IAT_MAX_AGE_MINUTES` | 15 | Max age of `iat` (minutes). | +| `ECT_IAT_MAX_FUTURE_SEC` | 30 | Max `iat` in future / clock skew (seconds). | +| `ECT_DEFAULT_EXPIRY_MIN` | 10 | Default token expiry when `exp` is zero (minutes). | +| `ECT_JTI_REPLAY_CACHE_SIZE` | 0 | Max JTIs to remember for replay check; 0 = disabled. | +| `ECT_JTI_REPLAY_TTL_MIN` | 60 | TTL for JTI cache entries (minutes). | + +### Replay cache (multi-instance) + +`JTICache` is in-memory only. For multiple verifier instances (e.g. behind a load balancer), use a shared store (Redis, database) so every instance sees the same “seen” JTIs. Implement `JTISeen` as a function that checks (and optionally records) the JTI in that store (e.g. with TTL). Pass it in `VerifyOptions.JTISeen`. See refimpl/README for an overview. + +## Dependencies + +- [go-jose/v4](https://github.com/go-jose/go-jose/v4) for JWS (ES256) and JWK handling. + +## License + +Same as the Internet-Draft (IETF Trust). Code under Revised BSD per BCP 78/79. diff --git a/refimpl/cmd/demo/main.go b/refimpl/go-lang/cmd/demo/main.go similarity index 80% rename from refimpl/cmd/demo/main.go rename to refimpl/go-lang/cmd/demo/main.go index f4a5566..ff748e9 100644 --- a/refimpl/cmd/demo/main.go +++ b/refimpl/go-lang/cmd/demo/main.go @@ -9,7 +9,7 @@ import ( "log" "time" - "github.com/nennemann/ect-refimpl/ect" + "github.com/nennemann/ect-refimpl/go-lang/ect" ) func main() { @@ -25,15 +25,14 @@ func main() { agentB := "spiffe://example.com/agent/implementer" kidA := "agent-a-key" - // 1) Agent A creates root ECT + // 1) Agent A creates root ECT (task id = jti per spec) payloadA := &ect.Payload{ Iss: agentA, Aud: []string{agentB}, Iat: now.Unix(), Exp: now.Add(10 * time.Minute).Unix(), - Jti: "jti-a-001", + Jti: "550e8400-e29b-41d4-a716-446655440001", Wid: "wf-demo-001", - Tid: "task-001", ExecAct: "review_requirements_spec", Par: []string{}, Pol: "spec_review_policy_v2", @@ -43,7 +42,7 @@ func main() { if err != nil { log.Fatal(err) } - fmt.Println("Agent A created root ECT (task-001, review_requirements_spec)") + fmt.Println("Agent A created root ECT (jti=550e8400-..., review_requirements_spec)") // 2) Agent B verifies (no store for DAG on first ECT) resolveKey := func(kid string) (*ecdsa.PublicKey, error) { @@ -70,7 +69,7 @@ func main() { } fmt.Println("Agent B verified root ECT and appended to ledger") - // 3) Agent B creates child ECT (depends on task-001) + // 3) Agent B creates child ECT (par contains parent jti values per spec) keyB, _ := ect.GenerateKey() kidB := "agent-b-key" payloadB := &ect.Payload{ @@ -78,11 +77,10 @@ func main() { Aud: []string{"spiffe://example.com/system/ledger"}, Iat: now.Unix() + 1, Exp: now.Add(10 * time.Minute).Unix(), - Jti: "jti-b-002", + Jti: "550e8400-e29b-41d4-a716-446655440002", Wid: "wf-demo-001", - Tid: "task-002", ExecAct: "implement_module", - Par: []string{"task-001"}, + Par: []string{"550e8400-e29b-41d4-a716-446655440001"}, Pol: "coding_standards_v3", PolDecision: ect.PolDecisionApproved, } @@ -90,7 +88,7 @@ func main() { if err != nil { log.Fatal(err) } - fmt.Println("Agent B created child ECT (task-002, implement_module, par=[task-001])") + fmt.Println("Agent B created child ECT (jti=550e8400-...002, implement_module, par=[parent jti])") // 4) Verify child ECT with DAG (ledger has task-001) resolverB := ect.KeyResolver(func(kid string) (*ecdsa.PublicKey, error) { @@ -119,5 +117,5 @@ func main() { log.Fatal(err) } fmt.Println("Verified child ECT with DAG validation and appended to ledger") - fmt.Printf("Ledger entries: task-001 (%s), task-002 (%s)\n", parsed.Payload.ExecAct, parsedB.Payload.ExecAct) + fmt.Printf("Ledger entries: %s (%s), %s (%s)\n", parsed.Payload.Jti, parsed.Payload.ExecAct, parsedB.Payload.Jti, parsedB.Payload.ExecAct) } diff --git a/refimpl/ect/audience.go b/refimpl/go-lang/ect/audience.go similarity index 100% rename from refimpl/ect/audience.go rename to refimpl/go-lang/ect/audience.go diff --git a/refimpl/go-lang/ect/config.go b/refimpl/go-lang/ect/config.go new file mode 100644 index 0000000..6bfa8f5 --- /dev/null +++ b/refimpl/go-lang/ect/config.go @@ -0,0 +1,84 @@ +package ect + +import ( + "os" + "strconv" + "time" +) + +// Env names for production configuration. All optional; zero values use defaults. +const ( + EnvIATMaxAgeMinutes = "ECT_IAT_MAX_AGE_MINUTES" // max age of iat (default 15) + EnvIATMaxFutureSec = "ECT_IAT_MAX_FUTURE_SEC" // max iat in future / clock skew (default 30) + EnvDefaultExpiryMin = "ECT_DEFAULT_EXPIRY_MIN" // default token expiry when exp is zero (default 10) + EnvJTIReplayCacheSize = "ECT_JTI_REPLAY_CACHE_SIZE" // max JTIs to remember for replay check (0 = disabled) + EnvJTIReplayTTLMin = "ECT_JTI_REPLAY_TTL_MIN" // TTL for JTI cache entries (default 60) +) + +// Config holds production-friendly settings loaded from environment. +type Config struct { + IATMaxAge time.Duration + IATMaxFuture time.Duration + DefaultExpiry time.Duration + JTIReplaySize int // 0 = no replay cache + JTIReplayTTL time.Duration +} + +// DefaultConfig returns in-process defaults (no env). +func DefaultConfig() Config { + return Config{ + IATMaxAge: 15 * time.Minute, + IATMaxFuture: 30 * time.Second, + DefaultExpiry: 10 * time.Minute, + JTIReplayTTL: 60 * time.Minute, + } +} + +// LoadConfigFromEnv returns Config with values from environment where set. +func LoadConfigFromEnv() Config { + c := DefaultConfig() + if v := os.Getenv(EnvIATMaxAgeMinutes); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + c.IATMaxAge = time.Duration(n) * time.Minute + } + } + if v := os.Getenv(EnvIATMaxFutureSec); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + c.IATMaxFuture = time.Duration(n) * time.Second + } + } + if v := os.Getenv(EnvDefaultExpiryMin); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + c.DefaultExpiry = time.Duration(n) * time.Minute + } + } + if v := os.Getenv(EnvJTIReplayCacheSize); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + c.JTIReplaySize = n + } + } + if v := os.Getenv(EnvJTIReplayTTLMin); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + c.JTIReplayTTL = time.Duration(n) * time.Minute + } + } + return c +} + +// CreateOptions returns CreateOptions from this config. +func (c Config) CreateOptions(keyID string) CreateOptions { + return CreateOptions{ + KeyID: keyID, + IATMaxAge: c.IATMaxAge, + DefaultExpiry: c.DefaultExpiry, + } +} + +// VerifyOptions returns VerifyOptions from this config (VerifierID, ResolveKey, Store must be set by caller). +func (c Config) VerifyOptions() VerifyOptions { + opts := DefaultVerifyOptions() + opts.IATMaxAge = c.IATMaxAge + opts.IATMaxFuture = c.IATMaxFuture + opts.DAG = DefaultDAGConfig() + return opts +} diff --git a/refimpl/go-lang/ect/config_test.go b/refimpl/go-lang/ect/config_test.go new file mode 100644 index 0000000..e7cacd1 --- /dev/null +++ b/refimpl/go-lang/ect/config_test.go @@ -0,0 +1,79 @@ +package ect + +import ( + "os" + "testing" + "time" +) + +func TestDefaultConfig(t *testing.T) { + c := DefaultConfig() + if c.IATMaxAge != 15*time.Minute { + t.Errorf("IATMaxAge: got %v", c.IATMaxAge) + } + if c.JTIReplaySize != 0 { + t.Errorf("JTIReplaySize: got %d", c.JTIReplaySize) + } +} + +func TestLoadConfigFromEnv(t *testing.T) { + os.Setenv(EnvIATMaxAgeMinutes, "20") + os.Setenv(EnvJTIReplayCacheSize, "1000") + os.Setenv(EnvIATMaxFutureSec, "45") + os.Setenv(EnvDefaultExpiryMin, "12") + os.Setenv(EnvJTIReplayTTLMin, "90") + defer func() { + os.Unsetenv(EnvIATMaxAgeMinutes) + os.Unsetenv(EnvJTIReplayCacheSize) + os.Unsetenv(EnvIATMaxFutureSec) + os.Unsetenv(EnvDefaultExpiryMin) + os.Unsetenv(EnvJTIReplayTTLMin) + }() + c := LoadConfigFromEnv() + if c.IATMaxAge != 20*time.Minute { + t.Errorf("IATMaxAge from env: got %v", c.IATMaxAge) + } + if c.JTIReplaySize != 1000 { + t.Errorf("JTIReplaySize from env: got %d", c.JTIReplaySize) + } + if c.IATMaxFuture != 45*time.Second { + t.Errorf("IATMaxFuture: got %v", c.IATMaxFuture) + } + if c.DefaultExpiry != 12*time.Minute { + t.Errorf("DefaultExpiry: got %v", c.DefaultExpiry) + } + if c.JTIReplayTTL != 90*time.Minute { + t.Errorf("JTIReplayTTL: got %v", c.JTIReplayTTL) + } +} + +func TestLoadConfigFromEnv_invalidInt(t *testing.T) { + os.Setenv(EnvIATMaxAgeMinutes, "bad") + defer os.Unsetenv(EnvIATMaxAgeMinutes) + c := LoadConfigFromEnv() + if c.IATMaxAge != 15*time.Minute { + t.Errorf("invalid int should keep default: got %v", c.IATMaxAge) + } +} + +func TestConfig_CreateOptions(t *testing.T) { + c := DefaultConfig() + opts := c.CreateOptions("my-kid") + if opts.KeyID != "my-kid" { + t.Errorf("KeyID: got %q", opts.KeyID) + } + if opts.DefaultExpiry != c.DefaultExpiry { + t.Errorf("DefaultExpiry: got %v", opts.DefaultExpiry) + } +} + +func TestConfig_VerifyOptions(t *testing.T) { + c := DefaultConfig() + opts := c.VerifyOptions() + if opts.IATMaxAge != c.IATMaxAge { + t.Errorf("IATMaxAge: got %v", opts.IATMaxAge) + } + if opts.DAG.ClockSkewTolerance != DefaultClockSkewTolerance { + t.Errorf("DAG.ClockSkewTolerance: got %d", opts.DAG.ClockSkewTolerance) + } +} diff --git a/refimpl/ect/create.go b/refimpl/go-lang/ect/create.go similarity index 57% rename from refimpl/ect/create.go rename to refimpl/go-lang/ect/create.go index f738da5..04d6afc 100644 --- a/refimpl/ect/create.go +++ b/refimpl/go-lang/ect/create.go @@ -15,10 +15,14 @@ import ( type CreateOptions struct { // KeyID is the kid header value (references public key from WIT). KeyID string - // NotBeforeIATWindow caps how far in the past iat may be (recommended 15 min). + // IATMaxAge caps how far in the past iat may be (recommended 15 min). IATMaxAge time.Duration // DefaultExpiry is added to time.Now() for exp when zero (recommended 5–15 min). DefaultExpiry time.Duration + // ValidateUUIDs when true requires jti and wid (if set) to be UUID format (RFC 9562). + ValidateUUIDs bool + // MaxParLength is the max number of parent references (0 = no limit; recommended 100). + MaxParLength int } // DefaultCreateOptions returns recommended defaults. @@ -31,12 +35,13 @@ func DefaultCreateOptions() CreateOptions { // Create builds and signs an ECT. Payload must have required claims set; // Iat/Exp can be zero to use defaults (now, now+DefaultExpiry). +// Create may modify the payload in place (Iat, Exp, Sub, Par) when filling defaults; pass a copy if the original must stay unchanged. func Create(payload *Payload, privateKey *ecdsa.PrivateKey, opts CreateOptions) (compact string, err error) { if payload == nil || privateKey == nil { - return "", errors.New("ect: payload and privateKey required") + return "", ErrPayloadRequired } if opts.KeyID == "" { - return "", errors.New("ect: KeyID required") + return "", ErrKeyIDRequired } now := time.Now() if payload.Iat == 0 { @@ -55,7 +60,7 @@ func Create(payload *Payload, privateKey *ecdsa.PrivateKey, opts CreateOptions) payload.Par = []string{} } - if err := validatePayloadForCreate(payload); err != nil { + if err := validatePayloadForCreate(payload, opts); err != nil { return "", err } @@ -84,7 +89,7 @@ func Create(payload *Payload, privateKey *ecdsa.PrivateKey, opts CreateOptions) return jws.CompactSerialize() } -func validatePayloadForCreate(p *Payload) error { +func validatePayloadForCreate(p *Payload, opts CreateOptions) error { if p.Iss == "" { return errors.New("ect: iss required") } @@ -94,20 +99,49 @@ func validatePayloadForCreate(p *Payload) error { if p.Jti == "" { return errors.New("ect: jti required") } - if p.Tid == "" { - return errors.New("ect: tid required") - } if p.ExecAct == "" { return errors.New("ect: exec_act required") } - if p.Pol == "" { - return errors.New("ect: pol required") + if opts.ValidateUUIDs { + if !ValidUUID(p.Jti) { + return ErrInvalidJTI + } + if p.Wid != "" && !ValidUUID(p.Wid) { + return ErrInvalidWID + } } - if !ValidPolDecision(p.PolDecision) { - return errors.New("ect: pol_decision must be approved, rejected, or pending_human_review") + if opts.MaxParLength > 0 && len(p.Par) > opts.MaxParLength { + return ErrParLength } - if p.CompensationReason != "" && !p.CompensationRequired { - return errors.New("ect: compensation_reason requires compensation_required true") + if p.InpHash != "" { + if err := ValidateHashFormat(p.InpHash); err != nil { + return err + } + } + if p.OutHash != "" { + if err := ValidateHashFormat(p.OutHash); err != nil { + return err + } + } + if err := ValidateExt(p.Ext); err != nil { + return err + } + // pol/pol_decision are OPTIONAL; if either is set, both must be present and valid + if p.Pol != "" || p.PolDecision != "" { + if p.Pol == "" || p.PolDecision == "" { + return ErrPolPolDecisionPair + } + if !ValidPolDecision(p.PolDecision) { + return ErrInvalidPolDecision + } + } + // compensation_* live in ext per spec + if p.Ext != nil { + if _, hasReason := p.Ext["compensation_reason"]; hasReason { + if v, _ := p.Ext["compensation_required"].(bool); !v { + return errors.New("ect: ext.compensation_reason requires ext.compensation_required true") + } + } } return nil } diff --git a/refimpl/go-lang/ect/create_test.go b/refimpl/go-lang/ect/create_test.go new file mode 100644 index 0000000..77e2d2f --- /dev/null +++ b/refimpl/go-lang/ect/create_test.go @@ -0,0 +1,192 @@ +package ect + +import ( + "crypto/ecdsa" + "encoding/json" + "os" + "testing" + "time" +) + +func TestCreateRoundtrip(t *testing.T) { + key, err := GenerateKey() + if err != nil { + t.Fatal(err) + } + now := time.Now() + payload := &Payload{ + Iss: "spiffe://example.com/agent/a", + Aud: []string{"spiffe://example.com/agent/b"}, + Iat: now.Unix(), + Exp: now.Add(10 * time.Minute).Unix(), + Jti: "e4f5a6b7-c8d9-0123-ef01-234567890abc", + ExecAct: "review_spec", + Par: []string{}, + Pol: "spec_review_policy_v2", + PolDecision: PolDecisionApproved, + } + compact, err := Create(payload, key, CreateOptions{KeyID: "agent-a-key-1"}) + if err != nil { + t.Fatal(err) + } + if compact == "" { + t.Fatal("expected non-empty compact JWS") + } + + // Verify with same key + resolver := func(kid string) (*ecdsa.PublicKey, error) { + if kid != "agent-a-key-1" { + return nil, nil + } + return &key.PublicKey, nil + } + opts := VerifyOptions{ + VerifierID: "spiffe://example.com/agent/b", + ResolveKey: resolver, + Now: now, + IATMaxAge: 15 * time.Minute, + IATMaxFuture: 30 * time.Second, + } + parsed, err := Verify(compact, opts) + if err != nil { + t.Fatal(err) + } + if parsed.Payload.Jti != payload.Jti || parsed.Payload.ExecAct != payload.ExecAct { + t.Errorf("payload mismatch: got jti=%q exec_act=%q", parsed.Payload.Jti, parsed.Payload.ExecAct) + } +} + +func TestDefaultCreateOptions(t *testing.T) { + opts := DefaultCreateOptions() + if opts.KeyID != "" { + t.Errorf("KeyID: got %q", opts.KeyID) + } + if opts.DefaultExpiry != 10*time.Minute { + t.Errorf("DefaultExpiry: got %v", opts.DefaultExpiry) + } +} + +func TestCreate_Errors(t *testing.T) { + key, _ := GenerateKey() + payload := &Payload{Iss: "i", Aud: []string{"a"}, Jti: "j", ExecAct: "e", Par: []string{}, Pol: "p", PolDecision: PolDecisionApproved, Iat: 1, Exp: 2} + if _, err := Create(nil, key, CreateOptions{KeyID: "k"}); err == nil { + t.Error("expected error for nil payload") + } + if _, err := Create(payload, nil, CreateOptions{KeyID: "k"}); err == nil { + t.Error("expected error for nil key") + } + if _, err := Create(payload, key, CreateOptions{KeyID: ""}); err == nil { + t.Error("expected error for empty KeyID") + } +} + +func TestCreate_OptionalPol(t *testing.T) { + key, _ := GenerateKey() + now := time.Now() + payload := &Payload{ + Iss: "iss", Aud: []string{"aud"}, Iat: now.Unix(), Exp: now.Add(time.Hour).Unix(), + Jti: "jti-nopol", ExecAct: "act", Par: []string{}, + } + compact, err := Create(payload, key, CreateOptions{KeyID: "kid"}) + if err != nil { + t.Fatal(err) + } + if compact == "" { + t.Fatal("expected token without pol") + } +} + +func TestCreate_ZeroExpiryUsesDefault(t *testing.T) { + key, _ := GenerateKey() + payload := &Payload{ + Iss: "i", Aud: []string{"a"}, Iat: 0, Exp: 0, + Jti: "jti-z", ExecAct: "e", Par: []string{}, + } + _, err := Create(payload, key, CreateOptions{KeyID: "kid", DefaultExpiry: 5 * time.Minute}) + if err != nil { + t.Fatal(err) + } + if payload.Exp <= payload.Iat { + t.Error("exp should be after iat") + } +} + +func TestCreate_ExtCompensationReasonRequiresRequired(t *testing.T) { + key, _ := GenerateKey() + payload := &Payload{ + Iss: "i", Aud: []string{"a"}, Iat: 1, Exp: 2, + Jti: "j", ExecAct: "e", Par: []string{}, + Ext: map[string]interface{}{"compensation_reason": "rollback", "compensation_required": false}, + } + _, err := Create(payload, key, CreateOptions{KeyID: "k"}) + if err == nil { + t.Fatal("expected error when ext has compensation_reason without compensation_required true") + } +} + +func TestCreate_ValidationErrors(t *testing.T) { + key, _ := GenerateKey() + tests := []struct { + name string + p *Payload + }{ + {"missing iss", &Payload{Iss: "", Aud: []string{"a"}, Jti: "j", ExecAct: "e", Par: []string{}, Iat: 1, Exp: 2}}, + {"missing aud", &Payload{Iss: "i", Aud: nil, Jti: "j", ExecAct: "e", Par: []string{}, Iat: 1, Exp: 2}}, + {"missing jti", &Payload{Iss: "i", Aud: []string{"a"}, Jti: "", ExecAct: "e", Par: []string{}, Iat: 1, Exp: 2}}, + {"missing exec_act", &Payload{Iss: "i", Aud: []string{"a"}, Jti: "j", ExecAct: "", Par: []string{}, Iat: 1, Exp: 2}}, + {"pol without pol_decision", &Payload{Iss: "i", Aud: []string{"a"}, Jti: "j", ExecAct: "e", Par: []string{}, Pol: "p", PolDecision: "", Iat: 1, Exp: 2}}, + {"invalid pol_decision", &Payload{Iss: "i", Aud: []string{"a"}, Jti: "j", ExecAct: "e", Par: []string{}, Pol: "p", PolDecision: "bad", Iat: 1, Exp: 2}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := Create(tt.p, key, CreateOptions{KeyID: "k"}); err == nil { + t.Error("expected validation error") + } + }) + } +} + +func TestCreateWithTestVector(t *testing.T) { + // testdata at module root (go-lang/testdata/); test cwd is package dir (ect/) + data, err := os.ReadFile("../testdata/valid_root_ect_payload.json") + if err != nil { + data, err = os.ReadFile("testdata/valid_root_ect_payload.json") + } + if err != nil { + t.Skipf("test vector not found: %v", err) + return + } + var p Payload + if err := json.Unmarshal(data, &p); err != nil { + t.Fatal(err) + } + key, err := GenerateKey() + if err != nil { + t.Fatal(err) + } + // Override timestamps for verification + now := time.Now() + p.Iat = now.Unix() + p.Exp = now.Add(10 * time.Minute).Unix() + + compact, err := Create(&p, key, CreateOptions{KeyID: "test-kid"}) + if err != nil { + t.Fatal(err) + } + resolver := func(kid string) (*ecdsa.PublicKey, error) { + if kid != "test-kid" { + return nil, nil + } + return &key.PublicKey, nil + } + _, err = Verify(compact, VerifyOptions{ + VerifierID: p.Aud[0], + ResolveKey: resolver, + Now: now, + IATMaxAge: 15 * time.Minute, + IATMaxFuture: 30 * time.Second, + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/refimpl/ect/dag.go b/refimpl/go-lang/ect/dag.go similarity index 79% rename from refimpl/ect/dag.go rename to refimpl/go-lang/ect/dag.go index c17a4ac..1bbf428 100644 --- a/refimpl/ect/dag.go +++ b/refimpl/go-lang/ect/dag.go @@ -24,6 +24,7 @@ type ECTStore interface { type DAGConfig struct { ClockSkewTolerance int // seconds; recommended 30 MaxAncestorLimit int // recommended 10000 + MaxParLength int // max par length (0 = no limit; recommended 100) } // DefaultDAGConfig returns recommended defaults. @@ -31,6 +32,7 @@ func DefaultDAGConfig() DAGConfig { return DAGConfig{ ClockSkewTolerance: DefaultClockSkewTolerance, MaxAncestorLimit: DefaultMaxAncestorLimit, + MaxParLength: DefaultMaxParLength, } } @@ -46,10 +48,13 @@ func ValidateDAG(ect *Payload, store ECTStore, cfg DAGConfig) error { if cfg.MaxAncestorLimit <= 0 { cfg.MaxAncestorLimit = DefaultMaxAncestorLimit } + if cfg.MaxParLength > 0 && len(ect.Par) > cfg.MaxParLength { + return ErrParLength + } - // 1. Task ID Uniqueness - if store.Contains(ect.Tid, ect.Wid) { - return fmt.Errorf("ect: task ID already exists: %s", ect.Tid) + // 1. Task ID Uniqueness (task id = jti per spec) + if store.Contains(ect.Jti, ect.Wid) { + return fmt.Errorf("ect: task ID (jti) already exists: %s", ect.Jti) } // 2. Parent Existence and 3. Temporal Ordering @@ -66,16 +71,17 @@ func ValidateDAG(ect *Payload, store ECTStore, cfg DAGConfig) error { // 4. Acyclicity (and depth limit) visited := make(map[string]struct{}) - if hasCycle(ect.Tid, ect.Par, store, visited, cfg.MaxAncestorLimit) { + if hasCycle(ect.Jti, ect.Par, store, visited, cfg.MaxAncestorLimit) { return errors.New("ect: circular dependency or depth limit exceeded") } - // 5. Parent Policy Decision + // 5. Parent Policy Decision (only when parent has policy claims per spec) for _, parentID := range ect.Par { parent := store.GetByTid(parentID) - if parent.PolDecision == PolDecisionRejected || parent.PolDecision == PolDecisionPendingHumanReview { - if !ect.CompensationRequired { - return errors.New("ect: parent has non-approved pol_decision; current ECT must be compensation/remediation or have compensation_required true") + if parent != nil && parent.HasPolicyClaims() && + (parent.PolDecision == PolDecisionRejected || parent.PolDecision == PolDecisionPendingHumanReview) { + if !ect.CompensationRequired() { + return errors.New("ect: parent has non-approved pol_decision; current ECT must be compensation/remediation or have ext.compensation_required true") } } } diff --git a/refimpl/go-lang/ect/dag_test.go b/refimpl/go-lang/ect/dag_test.go new file mode 100644 index 0000000..5e15bcb --- /dev/null +++ b/refimpl/go-lang/ect/dag_test.go @@ -0,0 +1,140 @@ +package ect + +import ( + "testing" + "time" +) + +func TestValidateDAG_Root(t *testing.T) { + store := NewMemoryLedger() + payload := &Payload{ + Jti: "jti-001", + Wid: "wf-1", + Par: []string{}, + PolDecision: PolDecisionApproved, + } + err := ValidateDAG(payload, store, DefaultDAGConfig()) + if err != nil { + t.Fatal(err) + } +} + +func TestValidateDAG_DuplicateJti(t *testing.T) { + store := NewMemoryLedger() + _, _ = store.Append("dummy-jws", &Payload{Jti: "jti-001", Wid: "wf-1", Par: []string{}, PolDecision: PolDecisionApproved}) + payload := &Payload{Jti: "jti-001", Wid: "wf-1", Par: []string{}, PolDecision: PolDecisionApproved} + err := ValidateDAG(payload, store, DefaultDAGConfig()) + if err == nil { + t.Fatal("expected error for duplicate jti") + } +} + +func TestValidateDAG_ParentExists(t *testing.T) { + store := NewMemoryLedger() + _, _ = store.Append("jws1", &Payload{Jti: "jti-001", Wid: "wf-1", Par: []string{}, PolDecision: PolDecisionApproved, Iat: time.Now().Unix() - 60}) + payload := &Payload{ + Jti: "jti-002", + Wid: "wf-1", + Par: []string{"jti-001"}, + PolDecision: PolDecisionApproved, + Iat: time.Now().Unix(), + } + err := ValidateDAG(payload, store, DefaultDAGConfig()) + if err != nil { + t.Fatal(err) + } +} + +func TestValidateDAG_ParentNotFound(t *testing.T) { + store := NewMemoryLedger() + payload := &Payload{ + Jti: "jti-002", + Par: []string{"jti-missing"}, + PolDecision: PolDecisionApproved, + Iat: time.Now().Unix(), + } + err := ValidateDAG(payload, store, DefaultDAGConfig()) + if err == nil { + t.Fatal("expected error when parent not found") + } +} + +func TestValidateDAG_DepthLimit(t *testing.T) { + store := NewMemoryLedger() + now := time.Now().Unix() + // Chain: jti-1 -> jti-2 -> jti-3 -> ...; validate with maxAncestorLimit=2 so we exceed it + _, _ = store.Append("jws1", &Payload{Jti: "jti-1", Wid: "wf", Par: []string{}, PolDecision: PolDecisionApproved, Iat: now - 100}) + _, _ = store.Append("jws2", &Payload{Jti: "jti-2", Wid: "wf", Par: []string{"jti-1"}, PolDecision: PolDecisionApproved, Iat: now - 50}) + cfg := DAGConfig{ClockSkewTolerance: DefaultClockSkewTolerance, MaxAncestorLimit: 2} + payload := &Payload{Jti: "jti-3", Wid: "wf", Par: []string{"jti-2"}, PolDecision: PolDecisionApproved, Iat: now} + err := ValidateDAG(payload, store, cfg) + if err == nil { + t.Fatal("expected error when ancestor limit exceeded") + } +} + +func TestValidateDAG_StoreNil(t *testing.T) { + payload := &Payload{Jti: "j1", Par: []string{}, PolDecision: PolDecisionApproved, Iat: time.Now().Unix()} + err := ValidateDAG(payload, nil, DefaultDAGConfig()) + if err == nil { + t.Fatal("expected error when store is nil") + } +} + +func TestValidateDAG_TemporalOrdering(t *testing.T) { + store := NewMemoryLedger() + now := time.Now().Unix() + _, _ = store.Append("jws1", &Payload{Jti: "jti-1", Wid: "wf", Par: []string{}, PolDecision: PolDecisionApproved, Iat: now}) + // child has iat before parent + skew: parent.iat (now) >= child.iat (now+100) + 30 => invalid + payload := &Payload{Jti: "jti-2", Wid: "wf", Par: []string{"jti-1"}, PolDecision: PolDecisionApproved, Iat: now + 100} + err := ValidateDAG(payload, store, DAGConfig{ClockSkewTolerance: 30, MaxAncestorLimit: 10000}) + if err != nil { + t.Fatal(err) + } + // parent.iat >= child.iat + skew: parent at now+50, child at now+10, skew 30 => 50 >= 40 => invalid + _, _ = store.Append("jws2", &Payload{Jti: "jti-1b", Wid: "wf", Par: []string{}, PolDecision: PolDecisionApproved, Iat: now + 50}) + payload2 := &Payload{Jti: "jti-2b", Wid: "wf", Par: []string{"jti-1b"}, PolDecision: PolDecisionApproved, Iat: now + 10} + err = ValidateDAG(payload2, store, DAGConfig{ClockSkewTolerance: 30, MaxAncestorLimit: 10000}) + if err == nil { + t.Fatal("expected error when parent not earlier than child") + } +} + +func TestValidateDAG_DirectCycle(t *testing.T) { + // par contains own jti (direct self-reference) -> parent not found + store := NewMemoryLedger() + now := time.Now().Unix() + payload := &Payload{Jti: "jti-self", Wid: "wf", Par: []string{"jti-self"}, PolDecision: PolDecisionApproved, Iat: now} + err := ValidateDAG(payload, store, DefaultDAGConfig()) + if err == nil { + t.Fatal("expected error for direct cycle (par contains self)") + } +} + +func TestValidateDAG_hasCycle_visitedContinue(t *testing.T) { + // par has duplicate parent ID so we hit "if _, ok := visited[parentID]; ok { continue }" + store := NewMemoryLedger() + now := time.Now().Unix() + _, _ = store.Append("jws1", &Payload{Jti: "jti-a", Wid: "wf", Par: []string{}, PolDecision: PolDecisionApproved, Iat: now - 10}) + payload := &Payload{Jti: "jti-b", Wid: "wf", Par: []string{"jti-a", "jti-a"}, PolDecision: PolDecisionApproved, Iat: now} + err := ValidateDAG(payload, store, DefaultDAGConfig()) + if err != nil { + t.Fatal(err) + } +} + +func TestValidateDAG_ParentPolicyRejected_RequiresCompensation(t *testing.T) { + store := NewMemoryLedger() + now := time.Now().Unix() + _, _ = store.Append("jws1", &Payload{Jti: "jti-rej", Wid: "wf", Par: []string{}, Pol: "p", PolDecision: PolDecisionRejected, Iat: now - 60}) + payload := &Payload{Jti: "jti-child", Wid: "wf", Par: []string{"jti-rej"}, PolDecision: PolDecisionApproved, Iat: now} + err := ValidateDAG(payload, store, DefaultDAGConfig()) + if err == nil { + t.Fatal("expected error when parent rejected and no compensation") + } + payload.Ext = map[string]interface{}{"compensation_required": true} + err = ValidateDAG(payload, store, DefaultDAGConfig()) + if err != nil { + t.Fatal(err) + } +} diff --git a/refimpl/go-lang/ect/errors.go b/refimpl/go-lang/ect/errors.go new file mode 100644 index 0000000..3142444 --- /dev/null +++ b/refimpl/go-lang/ect/errors.go @@ -0,0 +1,29 @@ +package ect + +import "errors" + +// Sentinel errors for programmatic handling and logging. +var ( + ErrPayloadRequired = errors.New("ect: payload and privateKey required") + ErrKeyIDRequired = errors.New("ect: KeyID required") + ErrInvalidTyp = errors.New("ect: invalid typ parameter") + ErrProhibitedAlg = errors.New("ect: prohibited algorithm") + ErrMissingKid = errors.New("ect: missing kid") + ErrUnknownKey = errors.New("ect: unknown key identifier") + ErrWITSubjectMismatch = errors.New("ect: issuer does not match WIT subject") + ErrAudienceMismatch = errors.New("ect: audience does not include verifier") + ErrExpired = errors.New("ect: token expired") + ErrIATTooOld = errors.New("ect: iat too far in the past") + ErrIATInFuture = errors.New("ect: iat in the future") + ErrMissingClaims = errors.New("ect: missing required claims (jti, exec_act, par)") + ErrPolPolDecisionPair = errors.New("ect: pol and pol_decision must both be present when either is set") + ErrInvalidPolDecision = errors.New("ect: invalid pol_decision value") + ErrReplay = errors.New("ect: jti already seen (replay)") + ErrResolveKeyRequired = errors.New("ect: ResolveKey required") + ErrExtSize = errors.New("ect: ext exceeds max size (4096 bytes)") + ErrExtDepth = errors.New("ect: ext exceeds max nesting depth (5)") + ErrInvalidJTI = errors.New("ect: jti must be UUID format") + ErrInvalidWID = errors.New("ect: wid must be UUID format when set") + ErrParLength = errors.New("ect: par exceeds max length") + ErrHashFormat = errors.New("ect: inp_hash/out_hash must be algorithm:base64url (e.g. sha-256:...)") +) diff --git a/refimpl/go-lang/ect/jti_cache.go b/refimpl/go-lang/ect/jti_cache.go new file mode 100644 index 0000000..03216ef --- /dev/null +++ b/refimpl/go-lang/ect/jti_cache.go @@ -0,0 +1,69 @@ +package ect + +import ( + "sync" + "time" +) + +// JTICache provides replay protection by remembering seen JTIs. Safe for concurrent use. +// Use as VerifyOptions.JTISeen: cache.Seen(jti) and call cache.Add(jti) after successful verify. +type JTICache interface { + // Seen returns true if jti was already added (replay). + Seen(jti string) bool + // Add records jti. Call after successful verification before accepting the token. + Add(jti string) +} + +type jtiEntry struct { + expiresAt time.Time +} + +// NewJTICache returns an in-memory JTI cache with optional max size and TTL. +// maxSize 0 means unbounded; entries are evicted after ttl. +func NewJTICache(maxSize int, ttl time.Duration) JTICache { + return &jtiCache{ + byJti: make(map[string]jtiEntry), + maxSize: maxSize, + ttl: ttl, + } +} + +type jtiCache struct { + mu sync.RWMutex + byJti map[string]jtiEntry + maxSize int + ttl time.Duration +} + +func (c *jtiCache) Seen(jti string) bool { + c.mu.RLock() + defer c.mu.RUnlock() + e, ok := c.byJti[jti] + if !ok { + return false + } + if time.Now().After(e.expiresAt) { + return false + } + return true +} + +func (c *jtiCache) Add(jti string) { + c.mu.Lock() + defer c.mu.Unlock() + now := time.Now() + // Evict expired + for k, v := range c.byJti { + if now.After(v.expiresAt) { + delete(c.byJti, k) + } + } + if c.maxSize > 0 && len(c.byJti) >= c.maxSize && c.byJti[jti].expiresAt.IsZero() { + // Evict one oldest (simple: remove first we see) + for k := range c.byJti { + delete(c.byJti, k) + break + } + } + c.byJti[jti] = jtiEntry{expiresAt: now.Add(c.ttl)} +} diff --git a/refimpl/go-lang/ect/jti_cache_test.go b/refimpl/go-lang/ect/jti_cache_test.go new file mode 100644 index 0000000..9ae40ce --- /dev/null +++ b/refimpl/go-lang/ect/jti_cache_test.go @@ -0,0 +1,56 @@ +package ect + +import ( + "testing" + "time" +) + +func TestJTICache_SeenAndAdd(t *testing.T) { + cache := NewJTICache(10, 1*time.Second) + if cache.Seen("jti-1") { + t.Error("unseen jti should not be seen") + } + cache.Add("jti-1") + if !cache.Seen("jti-1") { + t.Error("added jti should be seen") + } + if cache.Seen("jti-2") { + t.Error("other jti should not be seen") + } + cache.Add("jti-2") + if !cache.Seen("jti-2") { + t.Error("second jti should be seen") + } +} + +func TestJTICache_Expiry(t *testing.T) { + cache := NewJTICache(10, 50*time.Millisecond) + cache.Add("jti-1") + if !cache.Seen("jti-1") { + t.Fatal("added jti should be seen") + } + time.Sleep(60 * time.Millisecond) + if cache.Seen("jti-1") { + t.Error("expired jti should not be seen") + } +} + +func TestJTICache_MaxSizeEviction(t *testing.T) { + cache := NewJTICache(2, 5*time.Second) + cache.Add("jti-1") + cache.Add("jti-2") + cache.Add("jti-3") // evicts one + // At least one of jti-1, jti-2 may be evicted; jti-3 must be present + if !cache.Seen("jti-3") { + t.Error("newest jti should be present") + } +} + +func TestJTICache_AddWhenAlreadyPresent(t *testing.T) { + cache := NewJTICache(2, 5*time.Second) + cache.Add("jti-1") + cache.Add("jti-1") // refresh same jti; should not evict + if !cache.Seen("jti-1") { + t.Error("jti-1 should still be present") + } +} diff --git a/refimpl/ect/ledger.go b/refimpl/go-lang/ect/ledger.go similarity index 88% rename from refimpl/ect/ledger.go rename to refimpl/go-lang/ect/ledger.go index cca73ec..588dc47 100644 --- a/refimpl/ect/ledger.go +++ b/refimpl/go-lang/ect/ledger.go @@ -55,15 +55,15 @@ func (m *MemoryLedger) Append(ectJWS string, payload *Payload) (int64, error) { } m.mu.Lock() defer m.mu.Unlock() - // Uniqueness: tid must not already exist (scoped by wid when present) + // Uniqueness: jti (task id) must not already exist (scoped by wid when present) per spec wid := payload.Wid - if m.containsLocked(payload.Tid, wid) { + if m.containsLocked(payload.Jti, wid) { return 0, ErrTaskIDExists } m.seq++ entry := LedgerEntry{ LedgerSequence: m.seq, - TaskID: payload.Tid, + TaskID: payload.Jti, // task id = jti per spec AgentID: payload.Iss, Action: payload.ExecAct, Parents: append([]string(nil), payload.Par...), @@ -72,7 +72,7 @@ func (m *MemoryLedger) Append(ectJWS string, payload *Payload) (int64, error) { VerificationTime: time.Now().UTC(), StoredTime: time.Now().UTC(), } - m.byTid[payload.Tid] = payload + m.byTid[payload.Jti] = payload m.bySeq = append(m.bySeq, entry) m.entries = append(m.entries, entry) return m.seq, nil @@ -103,5 +103,5 @@ func (m *MemoryLedger) containsLocked(tid, wid string) bool { return p.Wid == wid } -// ErrTaskIDExists is returned when appending an ECT whose tid already exists. -var ErrTaskIDExists = errors.New("ect: task ID already exists in ledger") +// ErrTaskIDExists is returned when appending an ECT whose jti already exists. +var ErrTaskIDExists = errors.New("ect: task ID (jti) already exists in ledger") diff --git a/refimpl/go-lang/ect/ledger_test.go b/refimpl/go-lang/ect/ledger_test.go new file mode 100644 index 0000000..d8e26c1 --- /dev/null +++ b/refimpl/go-lang/ect/ledger_test.go @@ -0,0 +1,58 @@ +package ect + +import ( + "testing" + "time" +) + +func TestMemoryLedger_AppendAndGet(t *testing.T) { + m := NewMemoryLedger() + p := &Payload{Jti: "jti-1", Iss: "iss", ExecAct: "act", Par: []string{}, Iat: time.Now().Unix(), Exp: time.Now().Add(time.Hour).Unix()} + seq, err := m.Append("jws1", p) + if err != nil { + t.Fatal(err) + } + if seq != 1 { + t.Errorf("seq: got %d", seq) + } + got := m.GetByTid("jti-1") + if got == nil || got.Jti != "jti-1" { + t.Errorf("GetByTid: got %v", got) + } +} + +func TestMemoryLedger_ErrTaskIDExists(t *testing.T) { + m := NewMemoryLedger() + p := &Payload{Jti: "jti-dup", Iss: "i", ExecAct: "e", Par: []string{}, Iat: 1, Exp: 2} + _, _ = m.Append("jws1", p) + _, err := m.Append("jws2", p) + if err != ErrTaskIDExists { + t.Errorf("expected ErrTaskIDExists, got %v", err) + } +} + +func TestMemoryLedger_ContainsWid(t *testing.T) { + m := NewMemoryLedger() + p := &Payload{Jti: "j1", Wid: "wf1", Iss: "i", ExecAct: "e", Par: []string{}, Iat: 1, Exp: 2} + _, _ = m.Append("jws", p) + if !m.Contains("j1", "") { + t.Error("Contains(j1, \"\") should be true") + } + if !m.Contains("j1", "wf1") { + t.Error("Contains(j1, wf1) should be true") + } + if m.Contains("j1", "wf2") { + t.Error("Contains(j1, wf2) should be false") + } +} + +func TestMemoryLedger_AppendNilPayload(t *testing.T) { + m := NewMemoryLedger() + seq, err := m.Append("jws", nil) + if err != nil { + t.Fatal(err) + } + if seq != 0 { + t.Errorf("Append(nil) should return 0, got %d", seq) + } +} diff --git a/refimpl/ect/types.go b/refimpl/go-lang/ect/types.go similarity index 70% rename from refimpl/ect/types.go rename to refimpl/go-lang/ect/types.go index c4ca3d7..6eafafb 100644 --- a/refimpl/ect/types.go +++ b/refimpl/go-lang/ect/types.go @@ -24,34 +24,25 @@ type Payload struct { Exp int64 `json:"exp"` // REQUIRED Jti string `json:"jti"` // REQUIRED: UUID - // Execution context (Section 4.2.2) + // Execution context (Section 4.2.2 / exec-claims) + // Task identity is jti only; no separate "tid" claim per spec. Wid string `json:"wid,omitempty"` // OPTIONAL: workflow ID, UUID - Tid string `json:"tid"` // REQUIRED: task ID, UUID ExecAct string `json:"exec_act"` // REQUIRED - Par []string `json:"par"` // REQUIRED: parent task IDs + Par []string `json:"par"` // REQUIRED: parent jti values - // Policy evaluation (Section 4.2.3) - Pol string `json:"pol"` // REQUIRED - PolDecision string `json:"pol_decision"` // REQUIRED: approved | rejected | pending_human_review - PolEnforcer string `json:"pol_enforcer,omitempty"` // OPTIONAL - PolTimestamp int64 `json:"pol_timestamp,omitempty"` // OPTIONAL + // Policy evaluation (Section 4.2.3 / policy-claims) — OPTIONAL + Pol string `json:"pol,omitempty"` + PolDecision string `json:"pol_decision,omitempty"` + PolEnforcer string `json:"pol_enforcer,omitempty"` + PolTimestamp int64 `json:"pol_timestamp,omitempty"` // Data integrity (Section 4.2.4) InpHash string `json:"inp_hash,omitempty"` OutHash string `json:"out_hash,omitempty"` InpClassification string `json:"inp_classification,omitempty"` - // Task metadata (Section 4.2.5) - ExecTimeMs int `json:"exec_time_ms,omitempty"` - RegulatedDomain string `json:"regulated_domain,omitempty"` - ModelVersion string `json:"model_version,omitempty"` - WitnessedBy []string `json:"witnessed_by,omitempty"` - - // Compensation (Section 4.2.6) - CompensationRequired bool `json:"compensation_required,omitempty"` - CompensationReason string `json:"compensation_reason,omitempty"` - - // Extensions (Section 4.2.7) + // Extensions (Section 4.2.7): exec_time_ms, regulated_domain, model_version, + // witnessed_by, inp_classification, pol_timestamp, compensation_required, compensation_reason Ext map[string]interface{} `json:"ext,omitempty"` } @@ -95,3 +86,17 @@ func (p *Payload) IATTime() time.Time { func (p *Payload) ExpTime() time.Time { return time.Unix(p.Exp, 0) } + +// CompensationRequired returns true if ext["compensation_required"] is true (per spec extension). +func (p *Payload) CompensationRequired() bool { + if p.Ext == nil { + return false + } + v, _ := p.Ext["compensation_required"].(bool) + return v +} + +// HasPolicyClaims returns true if both pol and pol_decision are present (optional pair per spec). +func (p *Payload) HasPolicyClaims() bool { + return p.Pol != "" && p.PolDecision != "" +} diff --git a/refimpl/go-lang/ect/types_test.go b/refimpl/go-lang/ect/types_test.go new file mode 100644 index 0000000..4ab5a8d --- /dev/null +++ b/refimpl/go-lang/ect/types_test.go @@ -0,0 +1,115 @@ +package ect + +import ( + "encoding/json" + "testing" + "time" +) + +func TestAudience_MarshalJSON_single(t *testing.T) { + a := Audience{"single"} + b, err := json.Marshal(a) + if err != nil { + t.Fatal(err) + } + if string(b) != `"single"` { + t.Errorf("single aud: got %s", string(b)) + } +} + +func TestAudience_MarshalJSON_multi(t *testing.T) { + a := Audience{"a", "b"} + b, err := json.Marshal(a) + if err != nil { + t.Fatal(err) + } + if string(b) != `["a","b"]` { + t.Errorf("multi aud: got %s", string(b)) + } +} + +func TestAudience_UnmarshalJSON_array(t *testing.T) { + var a Audience + err := json.Unmarshal([]byte(`["x","y"]`), &a) + if err != nil { + t.Fatal(err) + } + if len(a) != 2 || a[0] != "x" || a[1] != "y" { + t.Errorf("unmarshal array: got %v", a) + } +} + +func TestAudience_UnmarshalJSON_string(t *testing.T) { + var a Audience + err := json.Unmarshal([]byte(`"single"`), &a) + if err != nil { + t.Fatal(err) + } + if len(a) != 1 || a[0] != "single" { + t.Errorf("unmarshal string: got %v", a) + } +} + +func TestAudience_UnmarshalJSON_invalid(t *testing.T) { + var a Audience + // aud must be string or array; object is invalid + err := json.Unmarshal([]byte(`{}`), &a) + if err == nil { + t.Error("expected error for invalid aud type") + } +} + +func TestValidPolDecision(t *testing.T) { + if !ValidPolDecision(PolDecisionApproved) { + t.Error("approved should be valid") + } + if !ValidPolDecision(PolDecisionRejected) { + t.Error("rejected should be valid") + } + if ValidPolDecision("invalid") { + t.Error("invalid should not be valid") + } +} + +func TestPayload_ContainsAudience(t *testing.T) { + p := &Payload{Aud: []string{"a", "b"}} + if !p.ContainsAudience("a") { + t.Error("should contain a") + } + if p.ContainsAudience("c") { + t.Error("should not contain c") + } +} + +func TestPayload_IATTime_ExpTime(t *testing.T) { + ts := int64(1000000) + p := &Payload{Iat: ts, Exp: ts + 100} + if !p.IATTime().Equal(time.Unix(ts, 0)) { + t.Error("IATTime mismatch") + } + if !p.ExpTime().Equal(time.Unix(ts+100, 0)) { + t.Error("ExpTime mismatch") + } +} + +func TestPayload_CompensationRequired(t *testing.T) { + p := &Payload{} + if p.CompensationRequired() { + t.Error("nil Ext should not be compensation") + } + p.Ext = map[string]interface{}{"compensation_required": true} + if !p.CompensationRequired() { + t.Error("ext compensation_required true should return true") + } +} + +func TestPayload_HasPolicyClaims(t *testing.T) { + p := &Payload{Pol: "p", PolDecision: PolDecisionApproved} + if !p.HasPolicyClaims() { + t.Error("both pol and pol_decision set should have policy claims") + } + p.Pol = "" + if p.HasPolicyClaims() { + t.Error("missing pol should not have policy claims") + } +} diff --git a/refimpl/go-lang/ect/validate.go b/refimpl/go-lang/ect/validate.go new file mode 100644 index 0000000..2b16510 --- /dev/null +++ b/refimpl/go-lang/ect/validate.go @@ -0,0 +1,83 @@ +package ect + +import ( + "encoding/base64" + "encoding/json" + "regexp" + "strings" +) + +// ExtMaxSize is the recommended max serialized size of ext (Section 4.2.7). +const ExtMaxSize = 4096 + +// ExtMaxDepth is the recommended max JSON nesting depth in ext. +const ExtMaxDepth = 5 + +// DefaultMaxParLength is the recommended max number of parent references. +const DefaultMaxParLength = 100 + +// uuidRegex matches RFC 9562 UUID: 8-4-4-4-12 hex. +var uuidRegex = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) + +// allowedHashAlgs are the spec-recommended hash algorithm prefixes for inp_hash/out_hash. +var allowedHashAlgs = map[string]bool{"sha-256": true, "sha-384": true, "sha-512": true} + +// ValidateExt returns an error if ext exceeds ExtMaxSize or ExtMaxDepth. +func ValidateExt(ext map[string]interface{}) error { + if ext == nil || len(ext) == 0 { + return nil + } + b, err := json.Marshal(ext) + if err != nil { + return err + } + if len(b) > ExtMaxSize { + return ErrExtSize + } + if depth(b) > ExtMaxDepth { + return ErrExtDepth + } + return nil +} + +func depth(b []byte) int { + var max, level int + for _, c := range b { + switch c { + case '{', '[': + level++ + if level > max { + max = level + } + case '}', ']': + level-- + } + } + return max +} + +// ValidUUID returns true if s is a UUID string (RFC 9562). +func ValidUUID(s string) bool { + return uuidRegex.MatchString(s) +} + +// ValidateHashFormat returns nil if s is empty or matches "algorithm:base64url" (sha-256, sha-384, sha-512). +func ValidateHashFormat(s string) error { + if s == "" { + return nil + } + idx := strings.Index(s, ":") + if idx <= 0 { + return ErrHashFormat + } + alg := strings.ToLower(s[:idx]) + if !allowedHashAlgs[alg] { + return ErrHashFormat + } + encoded := s[idx+1:] + if encoded == "" { + return ErrHashFormat + } + _, err := base64.RawURLEncoding.DecodeString(encoded) + return err +} diff --git a/refimpl/ect/verify.go b/refimpl/go-lang/ect/verify.go similarity index 68% rename from refimpl/ect/verify.go rename to refimpl/go-lang/ect/verify.go index 20500b5..7eea5f0 100644 --- a/refimpl/ect/verify.go +++ b/refimpl/go-lang/ect/verify.go @@ -2,6 +2,7 @@ package ect import ( "crypto/ecdsa" + "crypto/subtle" "encoding/json" "errors" "fmt" @@ -27,9 +28,9 @@ type VerifyOptions struct { VerifierID string // ResolveKey returns the public key for kid. Required. ResolveKey KeyResolver - // ECTStore for DAG validation. If nil, DAG validation is skipped (e.g. first hop or point-to-point without ledger). + // ECTStore for DAG validation. If nil, DAG validation is skipped. Store ECTStore - // DAGConfig for DAG validation. Used only if Store != nil. + // DAG for DAG validation. Used only if Store != nil. DAG DAGConfig // Now is the current time; if zero, time.Now() is used. Now time.Time @@ -41,6 +42,12 @@ type VerifyOptions struct { JTISeen func(jti string) bool // WITSubject if set must equal payload.iss (issuer must match WIT subject for this key). WITSubject string + // ValidateUUIDs when true requires jti and wid (if set) to be UUID format. + ValidateUUIDs bool + // MaxParLength caps par length (0 = no limit). Applied before DAG; DAG may also enforce via DAG.MaxParLength. + MaxParLength int + // LogVerify if set is called after verification with jti and any error (for observability). + LogVerify func(jti string, err error) } // DefaultVerifyOptions returns recommended defaults. @@ -79,7 +86,14 @@ func Parse(compact string) (*ParsedECT, error) { } // Verify performs full Section 7 verification and optional DAG validation. -func Verify(compact string, opts VerifyOptions) (*ParsedECT, error) { +func Verify(compact string, opts VerifyOptions) (parsed *ParsedECT, err error) { + var logJti string + defer func() { + if opts.LogVerify != nil { + opts.LogVerify(logJti, err) + } + }() + jws, err := jose.ParseSigned(compact, []jose.SignatureAlgorithm{jose.ES256}) if err != nil { return nil, err @@ -90,34 +104,31 @@ func Verify(compact string, opts VerifyOptions) (*ParsedECT, error) { sig := jws.Signatures[0] header := &sig.Header - // 1. Parse JWS — done - - // 2. typ must be wimse-exec+jwt + // 2. typ must be wimse-exec+jwt (constant-time compare) typ, _ := header.ExtraHeaders["typ"].(string) if typ == "" { - // try standard typ - typ = header.ExtraHeaders[jose.HeaderType].(string) + typ, _ = header.ExtraHeaders[jose.HeaderType].(string) } - if typ != ECTType { - return nil, errors.New("ect: invalid typ parameter") + if subtle.ConstantTimeCompare([]byte(typ), []byte(ECTType)) != 1 { + return nil, ErrInvalidTyp } // 3. alg not none and not symmetric if header.Algorithm == "none" || header.Algorithm == "HS256" || header.Algorithm == "HS384" || header.Algorithm == "HS512" { - return nil, errors.New("ect: prohibited algorithm") + return nil, ErrProhibitedAlg } // 4. kid references known key kid := header.KeyID if kid == "" { - return nil, errors.New("ect: missing kid") + return nil, ErrMissingKid } pub, err := opts.ResolveKey(kid) if err != nil { return nil, fmt.Errorf("ect: key resolution: %w", err) } if pub == nil { - return nil, errors.New("ect: unknown key identifier") + return nil, ErrUnknownKey } // 5. Verify JWS signature @@ -130,17 +141,22 @@ func Verify(compact string, opts VerifyOptions) (*ParsedECT, error) { if err := json.Unmarshal(payloadBytes, &p); err != nil { return nil, err } + logJti = p.Jti + + if err := ValidateExt(p.Ext); err != nil { + return nil, err + } // 6. Key revocation — caller's ResolveKey can return nil for revoked // 7. alg match WIT — we don't have WIT; optional WITSubject check below // 8. iss matches WIT subject if opts.WITSubject != "" && p.Iss != opts.WITSubject { - return nil, errors.New("ect: issuer does not match WIT subject") + return nil, ErrWITSubjectMismatch } // 9. aud contains verifier if opts.VerifierID != "" && !p.ContainsAudience(opts.VerifierID) { - return nil, errors.New("ect: audience does not include verifier") + return nil, ErrAudienceMismatch } // 10. exp not expired @@ -149,7 +165,7 @@ func Verify(compact string, opts VerifyOptions) (*ParsedECT, error) { now = time.Now() } if now.Unix() > p.Exp { - return nil, errors.New("ect: token expired") + return nil, ErrExpired } // 11. iat not too far past/future @@ -160,23 +176,49 @@ func Verify(compact string, opts VerifyOptions) (*ParsedECT, error) { opts.IATMaxFuture = 30 * time.Second } if now.Unix()-p.Iat > int64(opts.IATMaxAge.Seconds()) { - return nil, errors.New("ect: iat too far in the past") + return nil, ErrIATTooOld } if p.Iat > now.Unix()+int64(opts.IATMaxFuture.Seconds()) { - return nil, errors.New("ect: iat in the future") + return nil, ErrIATInFuture } - // 12. Required claims present - if p.Jti == "" || p.Tid == "" || p.ExecAct == "" || p.Pol == "" || p.PolDecision == "" { - return nil, errors.New("ect: missing required claims") + // 12. Required claims present (jti, exec_act, par) + if p.Jti == "" || p.ExecAct == "" { + return nil, ErrMissingClaims } if p.Par == nil { p.Par = []string{} } + if opts.MaxParLength > 0 && len(p.Par) > opts.MaxParLength { + return nil, ErrParLength + } + if opts.ValidateUUIDs { + if !ValidUUID(p.Jti) { + return nil, ErrInvalidJTI + } + if p.Wid != "" && !ValidUUID(p.Wid) { + return nil, ErrInvalidWID + } + } + if p.InpHash != "" { + if err := ValidateHashFormat(p.InpHash); err != nil { + return nil, err + } + } + if p.OutHash != "" { + if err := ValidateHashFormat(p.OutHash); err != nil { + return nil, err + } + } - // 13. pol_decision in registry - if !ValidPolDecision(p.PolDecision) { - return nil, errors.New("ect: invalid pol_decision value") + // 13. If pol or pol_decision present, both must be present and pol_decision in registry + if p.Pol != "" || p.PolDecision != "" { + if p.Pol == "" || p.PolDecision == "" { + return nil, ErrPolPolDecisionPair + } + if !ValidPolDecision(p.PolDecision) { + return nil, ErrInvalidPolDecision + } } // 14. DAG validation @@ -188,7 +230,7 @@ func Verify(compact string, opts VerifyOptions) (*ParsedECT, error) { // 15. Replay (jti seen) if opts.JTISeen != nil && opts.JTISeen(p.Jti) { - return nil, errors.New("ect: jti already seen (replay)") + return nil, ErrReplay } return &ParsedECT{Header: header, Payload: &p, Raw: compact}, nil diff --git a/refimpl/go-lang/ect/verify_test.go b/refimpl/go-lang/ect/verify_test.go new file mode 100644 index 0000000..5d0df8d --- /dev/null +++ b/refimpl/go-lang/ect/verify_test.go @@ -0,0 +1,243 @@ +package ect + +import ( + "crypto/ecdsa" + "errors" + "testing" + "time" +) + +func TestParse(t *testing.T) { + key, _ := GenerateKey() + now := time.Now() + payload := &Payload{ + Iss: "iss", + Aud: []string{"aud"}, + Iat: now.Unix(), + Exp: now.Add(time.Hour).Unix(), + Jti: "jti-parse", + ExecAct: "act", + Par: []string{}, + Pol: "pol", + PolDecision: PolDecisionApproved, + } + compact, err := Create(payload, key, CreateOptions{KeyID: "kid"}) + if err != nil { + t.Fatal(err) + } + parsed, err := Parse(compact) + if err != nil { + t.Fatal(err) + } + if parsed.Payload.Jti != "jti-parse" { + t.Errorf("jti: got %q", parsed.Payload.Jti) + } + if parsed.Raw != compact { + t.Error("Raw should match compact") + } +} + +func TestVerify_InvalidTyp(t *testing.T) { + // Create token then tamper header to test typ check would need different alg or manual JWS; + // instead test Verify with bad token + _, err := Verify("not-a-jws", VerifyOptions{}) + if err == nil { + t.Fatal("expected error for invalid JWS") + } +} + +func TestVerify_Expired(t *testing.T) { + key, _ := GenerateKey() + now := time.Now() + payload := &Payload{ + Iss: "iss", + Aud: []string{"verifier"}, + Iat: now.Add(-1 * time.Hour).Unix(), + Exp: now.Add(-1 * time.Minute).Unix(), + Jti: "jti-exp", + ExecAct: "act", + Par: []string{}, + Pol: "pol", + PolDecision: PolDecisionApproved, + } + compact, _ := Create(payload, key, CreateOptions{KeyID: "kid"}) + resolver := func(kid string) (*ecdsa.PublicKey, error) { + if kid == "kid" { + return &key.PublicKey, nil + } + return nil, nil + } + _, err := Verify(compact, VerifyOptions{ + VerifierID: "verifier", + ResolveKey: resolver, + Now: now, + }) + if err == nil { + t.Fatal("expected error for expired token") + } +} + +func TestVerify_Replay(t *testing.T) { + key, _ := GenerateKey() + now := time.Now() + payload := &Payload{ + Iss: "iss", + Aud: []string{"v"}, + Iat: now.Unix(), + Exp: now.Add(time.Hour).Unix(), + Jti: "jti-replay", + ExecAct: "act", + Par: []string{}, + Pol: "p", + PolDecision: PolDecisionApproved, + } + compact, _ := Create(payload, key, CreateOptions{KeyID: "kid"}) + resolver := func(kid string) (*ecdsa.PublicKey, error) { + if kid == "kid" { + return &key.PublicKey, nil + } + return nil, nil + } + opts := VerifyOptions{ + VerifierID: "v", + ResolveKey: resolver, + Now: now, + JTISeen: func(jti string) bool { return jti == "jti-replay" }, + } + _, err := Verify(compact, opts) + if err == nil { + t.Fatal("expected error for replay") + } +} + +func TestDefaultVerifyOptions(t *testing.T) { + opts := DefaultVerifyOptions() + if opts.IATMaxAge != 15*time.Minute { + t.Errorf("IATMaxAge: got %v", opts.IATMaxAge) + } + if opts.DAG.ClockSkewTolerance != DefaultClockSkewTolerance { + t.Errorf("DAG: got %d", opts.DAG.ClockSkewTolerance) + } +} + +func TestVerify_WITSubjectMismatch(t *testing.T) { + key, _ := GenerateKey() + now := time.Now() + payload := &Payload{ + Iss: "iss", Aud: []string{"v"}, Iat: now.Unix(), Exp: now.Add(time.Hour).Unix(), + Jti: "jti-wit", ExecAct: "act", Par: []string{}, Pol: "p", PolDecision: PolDecisionApproved, + } + compact, _ := Create(payload, key, CreateOptions{KeyID: "kid"}) + resolver := func(kid string) (*ecdsa.PublicKey, error) { + if kid == "kid" { + return &key.PublicKey, nil + } + return nil, nil + } + _, err := Verify(compact, VerifyOptions{ + VerifierID: "v", ResolveKey: resolver, Now: now, WITSubject: "other-iss", + }) + if err == nil { + t.Fatal("expected error for WIT subject mismatch") + } +} + +func TestVerify_IATTooFarPast(t *testing.T) { + key, _ := GenerateKey() + now := time.Now() + payload := &Payload{ + Iss: "iss", Aud: []string{"v"}, Iat: now.Add(-1 * time.Hour).Unix(), Exp: now.Add(time.Hour).Unix(), + Jti: "jti-iat", ExecAct: "act", Par: []string{}, Pol: "p", PolDecision: PolDecisionApproved, + } + compact, _ := Create(payload, key, CreateOptions{KeyID: "kid"}) + resolver := func(kid string) (*ecdsa.PublicKey, error) { + if kid == "kid" { + return &key.PublicKey, nil + } + return nil, nil + } + _, err := Verify(compact, VerifyOptions{ + VerifierID: "v", ResolveKey: resolver, Now: now, IATMaxAge: 30 * time.Minute, + }) + if err == nil { + t.Fatal("expected error for iat too far in past") + } +} + +func TestVerify_IATInFuture(t *testing.T) { + key, _ := GenerateKey() + now := time.Now() + payload := &Payload{ + Iss: "iss", Aud: []string{"v"}, Iat: now.Add(60 * time.Second).Unix(), Exp: now.Add(2 * time.Hour).Unix(), + Jti: "jti-fut", ExecAct: "act", Par: []string{}, Pol: "p", PolDecision: PolDecisionApproved, + } + compact, _ := Create(payload, key, CreateOptions{KeyID: "kid"}) + resolver := func(kid string) (*ecdsa.PublicKey, error) { + if kid == "kid" { + return &key.PublicKey, nil + } + return nil, nil + } + _, err := Verify(compact, VerifyOptions{ + VerifierID: "v", ResolveKey: resolver, Now: now, IATMaxFuture: 30 * time.Second, + }) + if err == nil { + t.Fatal("expected error for iat in future") + } +} + +func TestVerify_ResolveKeyError(t *testing.T) { + key, _ := GenerateKey() + now := time.Now() + payload := &Payload{ + Iss: "iss", Aud: []string{"v"}, Iat: now.Unix(), Exp: now.Add(time.Hour).Unix(), + Jti: "jti-err", ExecAct: "act", Par: []string{}, Pol: "p", PolDecision: PolDecisionApproved, + } + compact, _ := Create(payload, key, CreateOptions{KeyID: "kid"}) + resolver := func(kid string) (*ecdsa.PublicKey, error) { + return nil, errors.New("key lookup failed") + } + _, err := Verify(compact, VerifyOptions{ + VerifierID: "v", ResolveKey: resolver, Now: now, + }) + if err == nil { + t.Fatal("expected error from ResolveKey") + } +} + +func TestVerify_WithDAG(t *testing.T) { + key, _ := GenerateKey() + ledger := NewMemoryLedger() + now := time.Now() + root := &Payload{ + Iss: "iss", Aud: []string{"v"}, Iat: now.Unix(), Exp: now.Add(time.Hour).Unix(), + Jti: "jti-root", ExecAct: "act", Par: []string{}, Pol: "p", PolDecision: PolDecisionApproved, + } + compactRoot, _ := Create(root, key, CreateOptions{KeyID: "kid"}) + resolver := func(kid string) (*ecdsa.PublicKey, error) { + if kid == "kid" { + return &key.PublicKey, nil + } + return nil, nil + } + opts := VerifyOptions{ + VerifierID: "v", ResolveKey: resolver, Store: ledger, Now: now, + } + parsed, err := Verify(compactRoot, opts) + if err != nil { + t.Fatal(err) + } + _, _ = ledger.Append(compactRoot, parsed.Payload) + child := &Payload{ + Iss: "iss", Aud: []string{"v"}, Iat: now.Unix() + 1, Exp: now.Add(time.Hour).Unix(), + Jti: "jti-child", ExecAct: "act2", Par: []string{"jti-root"}, Pol: "p", PolDecision: PolDecisionApproved, + } + compactChild, _ := Create(child, key, CreateOptions{KeyID: "kid"}) + parsed2, err := Verify(compactChild, opts) + if err != nil { + t.Fatal(err) + } + if parsed2.Payload.Jti != "jti-child" { + t.Errorf("got %q", parsed2.Payload.Jti) + } +} diff --git a/refimpl/go.mod b/refimpl/go-lang/go.mod similarity index 68% rename from refimpl/go.mod rename to refimpl/go-lang/go.mod index 6a42d41..4b33388 100644 --- a/refimpl/go.mod +++ b/refimpl/go-lang/go.mod @@ -1,4 +1,4 @@ -module github.com/nennemann/ect-refimpl +module github.com/nennemann/ect-refimpl/go-lang go 1.22 diff --git a/refimpl/go.sum b/refimpl/go-lang/go.sum similarity index 100% rename from refimpl/go.sum rename to refimpl/go-lang/go.sum diff --git a/refimpl/go-lang/testdata/valid_root_ect_payload.json b/refimpl/go-lang/testdata/valid_root_ect_payload.json new file mode 100644 index 0000000..8105b0e --- /dev/null +++ b/refimpl/go-lang/testdata/valid_root_ect_payload.json @@ -0,0 +1 @@ +{"iss":"spiffe://example.com/agent/clinical","sub":"spiffe://example.com/agent/clinical","aud":"spiffe://example.com/agent/safety","iat":1772064150,"exp":1772064750,"jti":"7f3a8b2c-d1e4-4f56-9a0b-c3d4e5f6a7b8","wid":"a0b1c2d3-e4f5-6789-abcd-ef0123456789","exec_act":"recommend_treatment","par":[],"pol":"clinical_reasoning_policy_v2","pol_decision":"approved"} diff --git a/refimpl/python/README.md b/refimpl/python/README.md new file mode 100644 index 0000000..22dca70 --- /dev/null +++ b/refimpl/python/README.md @@ -0,0 +1,100 @@ +# 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). + +## Layout + +``` +python/ +├── pyproject.toml +├── ect/ # library +│ ├── __init__.py +│ ├── types.py # Payload, constants +│ ├── create.py # create(), generate_key() +│ ├── verify.py # parse(), verify(), VerifyOptions +│ ├── dag.py # validate_dag(), ECTStore, DAGConfig +│ ├── ledger.py # Ledger, MemoryLedger +│ ├── config.py # Config, load_config_from_env() +│ ├── jti_cache.py # JTICache for replay protection +│ └── validate.py # validate_ext, valid_uuid, validate_hash_format +├── tests/ +│ ├── test_create.py +│ └── test_dag.py +├── testdata/ +│ └── valid_root_ect_payload.json +└── demo.py # two-agent workflow demo +``` + +## Install + +```bash +cd refimpl/python && pip install -e . +``` + +## Usage + +```python +from ect import ( + Payload, + create, + generate_key, + CreateOptions, + verify, + VerifyOptions, + MemoryLedger, + POL_DECISION_APPROVED, +) + +cfg = load_config_from_env() +key = generate_key() +payload = Payload( + iss="spiffe://example.com/agent/a", + aud=["spiffe://example.com/agent/b"], + iat=int(time.time()), + exp=int(time.time()) + 600, + jti="550e8400-e29b-41d4-a716-446655440000", + exec_act="review_spec", + par=[], + pol="policy_v1", + pol_decision=POL_DECISION_APPROVED, +) +compact = create(payload, key, cfg.create_options("agent-a-key")) + +store = MemoryLedger() +opts = cfg.verify_options() +opts.verifier_id = "spiffe://example.com/agent/b" +opts.resolve_key = lambda kid: key.public_key() if kid == "agent-a-key" else None +opts.store = store +parsed = verify(compact, opts) +store.append(compact, parsed.payload) +``` + +## Demo + +```bash +cd refimpl/python && python3 demo.py +``` + +## Tests + +```bash +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. + +## 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`. + +### Replay cache (multi-instance) + +The provided JTI cache is in-memory only. For multiple verifier instances, use a shared store (Redis, DB) and pass a `jti_seen` callable that checks/records JTIs there. See refimpl/README for an overview. + +## Dependencies + +- PyJWT, cryptography (ES256). + +## License + +Same as the Internet-Draft (IETF Trust). Code under Revised BSD per BCP 78/79. diff --git a/refimpl/python/demo.py b/refimpl/python/demo.py new file mode 100644 index 0000000..4b9b5b2 --- /dev/null +++ b/refimpl/python/demo.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Two-agent ECT workflow demo: Agent A creates root ECT, Agent B verifies and creates child.""" + +import time + +from ect import ( + Payload, + create, + generate_key, + CreateOptions, + verify, + VerifyOptions, + MemoryLedger, + POL_DECISION_APPROVED, +) + +def main(): + ledger = MemoryLedger() + now = int(time.time()) + + key_a = generate_key() + agent_a = "spiffe://example.com/agent/spec-reviewer" + agent_b = "spiffe://example.com/agent/implementer" + kid_a = "agent-a-key" + + # 1) Agent A creates root ECT (task id = jti per spec) + root_jti = "550e8400-e29b-41d4-a716-446655440001" + payload_a = Payload( + iss=agent_a, + aud=[agent_b], + iat=now, + exp=now + 600, + jti=root_jti, + wid="wf-demo-001", + exec_act="review_requirements_spec", + par=[], + pol="spec_review_policy_v2", + pol_decision=POL_DECISION_APPROVED, + ) + ect_a = create(payload_a, key_a, CreateOptions(key_id=kid_a)) + print("Agent A created root ECT (jti=550e8400-..., review_requirements_spec)") + + # 2) Agent B verifies + def resolve_key(kid): + if kid == kid_a: + return key_a.public_key() + return None + + opts = VerifyOptions( + verifier_id=agent_b, + resolve_key=resolve_key, + store=ledger, + now=now, + ) + parsed = verify(ect_a, opts) + ledger.append(ect_a, parsed.payload) + print("Agent B verified root ECT and appended to ledger") + + # 3) Agent B creates child ECT (par contains parent jti values per spec) + key_b = generate_key() + kid_b = "agent-b-key" + child_jti = "550e8400-e29b-41d4-a716-446655440002" + payload_b = Payload( + iss=agent_b, + aud=["spiffe://example.com/system/ledger"], + iat=now + 1, + exp=now + 600, + jti=child_jti, + wid="wf-demo-001", + exec_act="implement_module", + par=[root_jti], + pol="coding_standards_v3", + pol_decision=POL_DECISION_APPROVED, + ) + ect_b = create(payload_b, key_b, CreateOptions(key_id=kid_b)) + print("Agent B created child ECT (jti=550e8400-...002, implement_module, par=[parent jti])") + + # 4) Verify child ECT with DAG + def resolver_b(kid): + if kid == kid_b: + return key_b.public_key() + if kid == kid_a: + return key_a.public_key() + return None + + opts_b = VerifyOptions( + verifier_id="spiffe://example.com/system/ledger", + resolve_key=resolver_b, + store=ledger, + now=now + 2, + ) + parsed_b = verify(ect_b, opts_b) + ledger.append(ect_b, parsed_b.payload) + print("Verified child ECT with DAG validation and appended to ledger") + print(f"Ledger entries: {parsed.payload.jti} ({parsed.payload.exec_act}), {parsed_b.payload.jti} ({parsed_b.payload.exec_act})") + + +if __name__ == "__main__": + main() diff --git a/refimpl/python/ect/__init__.py b/refimpl/python/ect/__init__.py new file mode 100644 index 0000000..7ec7c25 --- /dev/null +++ b/refimpl/python/ect/__init__.py @@ -0,0 +1,61 @@ +# WIMSE Execution Context Tokens (ECT) — Python reference implementation +# draft-nennemann-wimse-execution-context-00 + +from ect.types import ( + ECT_TYPE, + POL_DECISION_APPROVED, + POL_DECISION_REJECTED, + POL_DECISION_PENDING_HUMAN_REVIEW, + Payload, + valid_pol_decision, +) +from ect.create import create, generate_key, CreateOptions, default_create_options +from ect.verify import ( + ParsedECT, + parse, + verify, + VerifyOptions, + default_verify_options, + KeyResolver, +) +from ect.dag import ( + ECTStore, + DAGConfig, + default_dag_config, + validate_dag, +) +from ect.ledger import Ledger, MemoryLedger, LedgerEntry, ErrTaskIDExists +from ect.config import Config, default_config, load_config_from_env +from ect.jti_cache import JTICache, new_jti_cache + +__all__ = [ + "ECT_TYPE", + "POL_DECISION_APPROVED", + "POL_DECISION_REJECTED", + "POL_DECISION_PENDING_HUMAN_REVIEW", + "Payload", + "valid_pol_decision", + "create", + "generate_key", + "CreateOptions", + "default_create_options", + "ParsedECT", + "parse", + "verify", + "VerifyOptions", + "default_verify_options", + "KeyResolver", + "ECTStore", + "DAGConfig", + "default_dag_config", + "validate_dag", + "Ledger", + "MemoryLedger", + "LedgerEntry", + "ErrTaskIDExists", + "Config", + "default_config", + "load_config_from_env", + "JTICache", + "new_jti_cache", +] diff --git a/refimpl/python/ect/config.py b/refimpl/python/ect/config.py new file mode 100644 index 0000000..25ff8bc --- /dev/null +++ b/refimpl/python/ect/config.py @@ -0,0 +1,61 @@ +"""Production config from environment.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass + +ENV_IAT_MAX_AGE_MINUTES = "ECT_IAT_MAX_AGE_MINUTES" +ENV_IAT_MAX_FUTURE_SEC = "ECT_IAT_MAX_FUTURE_SEC" +ENV_DEFAULT_EXPIRY_MIN = "ECT_DEFAULT_EXPIRY_MIN" +ENV_JTI_REPLAY_CACHE_SIZE = "ECT_JTI_REPLAY_CACHE_SIZE" +ENV_JTI_REPLAY_TTL_MIN = "ECT_JTI_REPLAY_TTL_MIN" + + +@dataclass +class Config: + iat_max_age_sec: int = 900 + iat_max_future_sec: int = 30 + default_expiry_sec: int = 600 + jti_replay_size: int = 0 + jti_replay_ttl_sec: int = 3600 + + def create_options(self, key_id: str) -> "CreateOptions": + from ect.create import CreateOptions + return CreateOptions( + key_id=key_id, + default_expiry_sec=self.default_expiry_sec, + ) + + def verify_options(self) -> "VerifyOptions": + from ect.verify import VerifyOptions + from ect.dag import default_dag_config + return VerifyOptions( + iat_max_age_sec=self.iat_max_age_sec, + iat_max_future_sec=self.iat_max_future_sec, + dag=default_dag_config(), + ) + + +def default_config() -> Config: + return Config() + + +def _int_env(name: str, default: int) -> int: + v = os.environ.get(name) + if v is None or v == "": + return default + try: + return int(v) + except ValueError: + return default + + +def load_config_from_env() -> Config: + c = default_config() + c.iat_max_age_sec = _int_env(ENV_IAT_MAX_AGE_MINUTES, 15) * 60 + c.iat_max_future_sec = _int_env(ENV_IAT_MAX_FUTURE_SEC, 30) + c.default_expiry_sec = _int_env(ENV_DEFAULT_EXPIRY_MIN, 10) * 60 + c.jti_replay_size = _int_env(ENV_JTI_REPLAY_CACHE_SIZE, 0) + c.jti_replay_ttl_sec = _int_env(ENV_JTI_REPLAY_TTL_MIN, 60) * 60 + return c diff --git a/refimpl/python/ect/create.py b/refimpl/python/ect/create.py new file mode 100644 index 0000000..46cd2c0 --- /dev/null +++ b/refimpl/python/ect/create.py @@ -0,0 +1,115 @@ +"""ECT creation: build and sign JWT with ES256.""" + +from __future__ import annotations + +import copy +import time +from dataclasses import dataclass +from typing import Optional + +import jwt +from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey + +from ect.types import Payload, valid_pol_decision +from ect.validate import ( + DEFAULT_MAX_PAR_LENGTH, + validate_ext, + validate_hash_format, + valid_uuid, +) + + +@dataclass +class CreateOptions: + key_id: str + iat_max_age_sec: int = 900 # 15 min + default_expiry_sec: int = 600 # 10 min + validate_uuids: bool = False + max_par_length: int = 0 # 0 = no limit; use DEFAULT_MAX_PAR_LENGTH for 100 + + +def default_create_options() -> CreateOptions: + return CreateOptions(key_id="") + + +def _validate_payload(p: Payload, opts: CreateOptions) -> None: + if not p.iss: + raise ValueError("ect: iss required") + if not p.aud: + raise ValueError("ect: aud required") + if not p.jti: + raise ValueError("ect: jti required") + if not p.exec_act: + raise ValueError("ect: exec_act required") + if opts.validate_uuids: + if not valid_uuid(p.jti): + raise ValueError("ect: jti must be UUID format") + if p.wid and not valid_uuid(p.wid): + raise ValueError("ect: wid must be UUID format when set") + max_par = opts.max_par_length or 0 + if max_par > 0 and len(p.par) > max_par: + raise ValueError("ect: par exceeds max length") + if p.inp_hash: + validate_hash_format(p.inp_hash) + if p.out_hash: + validate_hash_format(p.out_hash) + validate_ext(p.ext) + # pol/pol_decision OPTIONAL; if either set, both must be present and valid + if p.pol or p.pol_decision: + if not p.pol or not p.pol_decision: + raise ValueError("ect: pol and pol_decision must both be present when either is set") + if not valid_pol_decision(p.pol_decision): + raise ValueError( + "ect: pol_decision must be approved, rejected, or pending_human_review" + ) + # compensation in ext per spec + if p.ext and p.ext.get("compensation_reason") and not p.ext.get("compensation_required"): + raise ValueError("ect: ext.compensation_reason requires ext.compensation_required true") + + +def create( + payload: Payload, + private_key: EllipticCurvePrivateKey, + opts: CreateOptions, +) -> str: + """Build and sign an ECT. Payload must have required claims; iat/exp can be 0 for defaults. + create() may modify the payload in place (iat, exp, sub, par) when filling defaults; + pass a copy if the original must stay unchanged. + """ + if not opts.key_id: + raise ValueError("ect: KeyID required") + + # Work on a copy so we do not mutate the caller's payload. + payload = copy.deepcopy(payload) + + now = int(time.time()) + if payload.iat == 0: + payload.iat = now + if payload.exp == 0: + payload.exp = now + (opts.default_expiry_sec or 600) + if not payload.sub: + payload.sub = payload.iss + if payload.par is None: + payload.par = [] + + _validate_payload(payload, opts) + + claims = payload.to_claims() + headers = { + "typ": "wimse-exec+jwt", + "alg": "ES256", + "kid": opts.key_id, + } + return jwt.encode( + claims, + private_key, + algorithm="ES256", + headers=headers, + ) + + +def generate_key() -> EllipticCurvePrivateKey: + """Create an ECDSA P-256 key for ES256 (testing/demo).""" + from cryptography.hazmat.primitives.asymmetric import ec + + return ec.generate_private_key(ec.SECP256R1()) diff --git a/refimpl/python/ect/dag.py b/refimpl/python/ect/dag.py new file mode 100644 index 0000000..5819625 --- /dev/null +++ b/refimpl/python/ect/dag.py @@ -0,0 +1,97 @@ +"""DAG validation per Section 6.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ect.types import Payload + +from ect.validate import DEFAULT_MAX_PAR_LENGTH + +DEFAULT_CLOCK_SKEW_TOLERANCE = 30 +DEFAULT_MAX_ANCESTOR_LIMIT = 10000 + + +class ECTStore(ABC): + """Lookup of ECTs by task ID for DAG validation.""" + + @abstractmethod + def get_by_tid(self, tid: str) -> "Payload | None": + pass + + @abstractmethod + def contains(self, tid: str, wid: str) -> bool: + pass + + +class DAGConfig: + def __init__( + self, + clock_skew_tolerance: int = DEFAULT_CLOCK_SKEW_TOLERANCE, + max_ancestor_limit: int = DEFAULT_MAX_ANCESTOR_LIMIT, + max_par_length: int = 0, + ): + self.clock_skew_tolerance = clock_skew_tolerance or DEFAULT_CLOCK_SKEW_TOLERANCE + self.max_ancestor_limit = max_ancestor_limit or DEFAULT_MAX_ANCESTOR_LIMIT + self.max_par_length = max_par_length or 0 + + +def default_dag_config() -> DAGConfig: + return DAGConfig() + + +def _has_cycle( + target_tid: str, + parent_ids: list[str], + store: ECTStore, + visited: set[str], + max_depth: int, +) -> bool: + if len(visited) >= max_depth: + return True + for parent_id in parent_ids: + if parent_id == target_tid: + return True + if parent_id in visited: + continue + visited.add(parent_id) + parent = store.get_by_tid(parent_id) + if parent is not None: + if _has_cycle(target_tid, parent.par, store, visited, max_depth): + return True + return False + + +def validate_dag( + payload: "Payload", + store: ECTStore, + cfg: DAGConfig, +) -> None: + """Section 6.2: uniqueness (by jti), parent existence, temporal ordering, acyclicity, parent policy.""" + if cfg.max_par_length > 0 and len(payload.par) > cfg.max_par_length: + raise ValueError("ect: par exceeds max length") + if store.contains(payload.jti, payload.wid or ""): + raise ValueError(f"ect: task ID (jti) already exists: {payload.jti}") + from ect.types import POL_DECISION_REJECTED, POL_DECISION_PENDING_HUMAN_REVIEW + + for parent_id in payload.par: + parent = store.get_by_tid(parent_id) + if parent is None: + raise ValueError(f"ect: parent task not found: {parent_id}") + if parent.iat >= payload.iat + cfg.clock_skew_tolerance: + raise ValueError(f"ect: parent task not earlier than current: {parent_id}") + + visited: set[str] = set() + if _has_cycle(payload.jti, payload.par, store, visited, cfg.max_ancestor_limit): + raise ValueError("ect: circular dependency or depth limit exceeded") + + # Parent policy decision: only when parent has policy claims per spec + for parent_id in payload.par: + parent = store.get_by_tid(parent_id) + if parent and parent.has_policy_claims() and parent.pol_decision in (POL_DECISION_REJECTED, POL_DECISION_PENDING_HUMAN_REVIEW): + if not payload.compensation_required(): + raise ValueError( + "ect: parent has non-approved pol_decision; current ECT must be compensation/remediation or have ext.compensation_required true" + ) diff --git a/refimpl/python/ect/jti_cache.py b/refimpl/python/ect/jti_cache.py new file mode 100644 index 0000000..94c51d7 --- /dev/null +++ b/refimpl/python/ect/jti_cache.py @@ -0,0 +1,52 @@ +"""JTI replay cache for production verification.""" + +from __future__ import annotations + +import threading +import time +from abc import ABC, abstractmethod + + +class JTICache(ABC): + @abstractmethod + def seen(self, jti: str) -> bool: + pass + + @abstractmethod + def add(self, jti: str) -> None: + pass + + +class _MemoryJTICache(JTICache): + def __init__(self, max_size: int, ttl_sec: int) -> None: + self._max_size = max_size + self._ttl_sec = ttl_sec + self._by_jti: dict[str, float] = {} + self._lock = threading.RLock() + + def seen(self, jti: str) -> bool: + with self._lock: + exp = self._by_jti.get(jti) + if exp is None: + return False + if time.time() > exp: + del self._by_jti[jti] + return False + return True + + def add(self, jti: str) -> None: + with self._lock: + now = time.time() + for k, exp in list(self._by_jti.items()): + if now > exp: + del self._by_jti[k] + if self._max_size > 0 and len(self._by_jti) >= self._max_size and jti not in self._by_jti: + # evict one + for k in self._by_jti: + del self._by_jti[k] + break + self._by_jti[jti] = now + self._ttl_sec + + +def new_jti_cache(max_size: int, ttl_sec: int) -> JTICache: + return _MemoryJTICache(max_size, ttl_sec) diff --git a/refimpl/python/ect/ledger.py b/refimpl/python/ect/ledger.py new file mode 100644 index 0000000..fef46fa --- /dev/null +++ b/refimpl/python/ect/ledger.py @@ -0,0 +1,97 @@ +"""Audit ledger per Section 9.""" + +from __future__ import annotations + +import time +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from ect.types import Payload + +if TYPE_CHECKING: + pass + + +class ErrTaskIDExists(Exception): + """Raised when appending an ECT whose tid already exists.""" + + +@dataclass +class LedgerEntry: + ledger_sequence: int + task_id: str + agent_id: str + action: str + parents: list[str] + ect_jws: str + signature_verified: bool + verification_timestamp: float + stored_timestamp: float + + +class Ledger(ABC): + """Append-only audit ledger; lookup by task id (jti).""" + + @abstractmethod + def append(self, ect_jws: str, payload: Payload) -> int: + """Returns new ledger sequence number.""" + pass + + @abstractmethod + def get_by_tid(self, tid: str) -> Payload | None: + pass + + @abstractmethod + def contains(self, tid: str, wid: str) -> bool: + pass + + +class MemoryLedger(Ledger): + """In-memory append-only ECT store implementing Ledger and ECTStore.""" + + def __init__(self) -> None: + self._seq = 0 + self._by_tid: dict[str, "Payload"] = {} + self._entries: list[LedgerEntry] = [] + self._lock = __import__("threading").Lock() + + def append(self, ect_jws: str, payload: Payload) -> int: + if payload is None: + return 0 + with self._lock: + wid = payload.wid or "" + if self._contains_locked(payload.jti, wid): + raise ErrTaskIDExists("ect: task ID (jti) already exists in ledger") + self._seq += 1 + now = time.time() + entry = LedgerEntry( + ledger_sequence=self._seq, + task_id=payload.jti, + agent_id=payload.iss, + action=payload.exec_act, + parents=list(payload.par) if payload.par else [], + ect_jws=ect_jws, + signature_verified=True, + verification_timestamp=now, + stored_timestamp=now, + ) + self._by_tid[payload.jti] = payload + self._entries.append(entry) + return self._seq + + def get_by_tid(self, tid: str) -> Payload | None: + with self._lock: + return self._by_tid.get(tid) + + def contains(self, tid: str, wid: str) -> bool: + with self._lock: + return self._contains_locked(tid, wid) + + def _contains_locked(self, tid: str, wid: str) -> bool: + p = self._by_tid.get(tid) + if p is None: + return False + if not wid: + return True + return (p.wid or "") == wid diff --git a/refimpl/python/ect/types.py b/refimpl/python/ect/types.py new file mode 100644 index 0000000..da1f443 --- /dev/null +++ b/refimpl/python/ect/types.py @@ -0,0 +1,128 @@ +"""ECT payload and claim types per draft Section 4.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Any + +ECT_TYPE = "wimse-exec+jwt" + +POL_DECISION_APPROVED = "approved" +POL_DECISION_REJECTED = "rejected" +POL_DECISION_PENDING_HUMAN_REVIEW = "pending_human_review" + + +def valid_pol_decision(s: str) -> bool: + return s in ( + POL_DECISION_APPROVED, + POL_DECISION_REJECTED, + POL_DECISION_PENDING_HUMAN_REVIEW, + ) + + +def _audience_serialize(aud: list[str]) -> str | list[str]: + if len(aud) == 1: + return aud[0] + return aud + + +def _audience_deserialize(raw: Any) -> list[str]: + if isinstance(raw, list): + return [str(x) for x in raw] + if isinstance(raw, str): + return [raw] + raise ValueError("aud must be string or array of strings") + + +@dataclass +class Payload: + """ECT JWT claims per Section 4. Task identity is jti only; no separate tid per spec.""" + + iss: str + aud: list[str] + iat: int + exp: int + jti: str + exec_act: str + par: list[str] + pol: str = "" + pol_decision: str = "" + sub: str = "" + wid: str = "" + pol_enforcer: str = "" + pol_timestamp: int = 0 + inp_hash: str = "" + out_hash: str = "" + inp_classification: str = "" + ext: dict[str, Any] = field(default_factory=dict) + + def to_claims(self) -> dict[str, Any]: + """Export as JWT claims. Compensation in ext per spec.""" + out: dict[str, Any] = { + "iss": self.iss, + "aud": _audience_serialize(self.aud), + "iat": self.iat, + "exp": self.exp, + "jti": self.jti, + "exec_act": self.exec_act, + "par": self.par, + } + if self.sub: + out["sub"] = self.sub + if self.wid: + out["wid"] = self.wid + if self.pol: + out["pol"] = self.pol + if self.pol_decision: + out["pol_decision"] = self.pol_decision + if self.pol_enforcer: + out["pol_enforcer"] = self.pol_enforcer + if self.pol_timestamp: + out["pol_timestamp"] = self.pol_timestamp + if self.inp_hash: + out["inp_hash"] = self.inp_hash + if self.out_hash: + out["out_hash"] = self.out_hash + if self.inp_classification: + out["inp_classification"] = self.inp_classification + if self.ext: + out["ext"] = dict(self.ext) + return out + + @classmethod + def from_claims(cls, claims: dict[str, Any]) -> Payload: + """Build Payload from JWT claims. Compensation read from ext per spec.""" + ext = claims.get("ext") or {} + return cls( + iss=claims["iss"], + aud=_audience_deserialize(claims["aud"]), + iat=int(claims["iat"]), + exp=int(claims["exp"]), + jti=claims["jti"], + exec_act=claims["exec_act"], + par=claims.get("par") or [], + pol=claims.get("pol", ""), + pol_decision=claims.get("pol_decision", ""), + sub=claims.get("sub", ""), + wid=claims.get("wid", ""), + pol_enforcer=claims.get("pol_enforcer", ""), + pol_timestamp=int(claims.get("pol_timestamp") or 0), + inp_hash=claims.get("inp_hash", ""), + out_hash=claims.get("out_hash", ""), + inp_classification=claims.get("inp_classification", ""), + ext=ext, + ) + + def contains_audience(self, verifier_id: str) -> bool: + return verifier_id in self.aud + + def compensation_required(self) -> bool: + """Per spec: compensation_required is in ext.""" + if not self.ext: + return False + return bool(self.ext.get("compensation_required")) + + def has_policy_claims(self) -> bool: + """True if both pol and pol_decision are present (optional pair per spec).""" + return bool(self.pol and self.pol_decision) diff --git a/refimpl/python/ect/validate.py b/refimpl/python/ect/validate.py new file mode 100644 index 0000000..0412f23 --- /dev/null +++ b/refimpl/python/ect/validate.py @@ -0,0 +1,65 @@ +"""Validation helpers: ext size/depth, UUID, inp_hash/out_hash format.""" + +from __future__ import annotations + +import base64 +import json +import re +from typing import Any + +EXT_MAX_SIZE = 4096 +EXT_MAX_DEPTH = 5 +DEFAULT_MAX_PAR_LENGTH = 100 + +_UUID_RE = re.compile( + r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" +) +_ALLOWED_HASH_ALGS = frozenset(("sha-256", "sha-384", "sha-512")) + + +def _json_depth(obj: Any, depth: int = 0) -> int: + if depth > EXT_MAX_DEPTH: + return depth + if isinstance(obj, dict): + return max((_json_depth(v, depth + 1) for v in obj.values()), default=depth + 1) + if isinstance(obj, list): + return max((_json_depth(x, depth + 1) for x in obj), default=depth + 1) + return depth + + +def validate_ext(ext: dict[str, Any] | None) -> None: + """Raise ValueError if ext exceeds EXT_MAX_SIZE or nesting depth EXT_MAX_DEPTH.""" + if not ext: + return + raw = json.dumps(ext) + if len(raw.encode("utf-8")) > EXT_MAX_SIZE: + raise ValueError("ect: ext exceeds max size (4096 bytes)") + if _json_depth(ext) > EXT_MAX_DEPTH: + raise ValueError("ect: ext exceeds max nesting depth (5)") + + +def valid_uuid(s: str) -> bool: + """Return True if s is a UUID string (RFC 9562).""" + return bool(_UUID_RE.match(s)) + + +def validate_hash_format(s: str) -> None: + """Raise ValueError if s is non-empty and not algorithm:base64url (sha-256, sha-384, sha-512).""" + if not s: + return + idx = s.find(":") + if idx <= 0: + raise ValueError("ect: inp_hash/out_hash must be algorithm:base64url (e.g. sha-256:...)") + alg = s[:idx].lower() + if alg not in _ALLOWED_HASH_ALGS: + raise ValueError("ect: inp_hash/out_hash must be algorithm:base64url (e.g. sha-256:...)") + encoded = s[idx + 1:] + 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: + base64.urlsafe_b64decode(encoded) + except Exception: + raise ValueError("ect: inp_hash/out_hash must be algorithm:base64url (e.g. sha-256:...)") from None diff --git a/refimpl/python/ect/verify.py b/refimpl/python/ect/verify.py new file mode 100644 index 0000000..9ffa8ce --- /dev/null +++ b/refimpl/python/ect/verify.py @@ -0,0 +1,160 @@ +"""ECT verification per Section 7.""" + +from __future__ import annotations + +import hmac +import time +from dataclasses import dataclass +from typing import Callable, Optional + +import jwt +from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicKey + +from ect.types import ECT_TYPE, Payload, valid_pol_decision +from ect.dag import ECTStore, DAGConfig, validate_dag +from ect.validate import validate_ext, validate_hash_format, valid_uuid + + +@dataclass +class ParsedECT: + header: dict + payload: Payload + raw: str + + +KeyResolver = Callable[[str], Optional[EllipticCurvePublicKey]] + + +@dataclass +class VerifyOptions: + verifier_id: str = "" + resolve_key: Optional[KeyResolver] = None + store: Optional[ECTStore] = None + dag: Optional[DAGConfig] = None + now: Optional[int] = None # unix seconds; None = time.time() + iat_max_age_sec: int = 900 + iat_max_future_sec: int = 30 + jti_seen: Optional[Callable[[str], bool]] = None + wit_subject: str = "" + validate_uuids: bool = False + max_par_length: int = 0 # 0 = no limit + on_verify_attempt: Optional[Callable[[str, Optional[Exception]], None]] = None # (jti, err) for observability + + +def default_verify_options() -> VerifyOptions: + from ect.dag import default_dag_config + return VerifyOptions(dag=default_dag_config()) + + +def parse(compact: str) -> ParsedECT: + """Parse compact JWS and return header + payload without verification.""" + try: + unverified = jwt.decode( + compact, + options={"verify_signature": False, "verify_exp": False}, + ) + except Exception as e: + raise ValueError(f"ect: parse failed: {e}") from e + header = jwt.get_unverified_header(compact) + if header.get("alg") != "ES256": + raise ValueError("ect: expected ES256") + payload = Payload.from_claims(unverified) + return ParsedECT(header=header, payload=payload, raw=compact) + + +def verify(compact: str, opts: VerifyOptions) -> ParsedECT: + """Full Section 7 verification and optional DAG validation.""" + log_jti: list[str] = [""] # use list so callback sees updated jti + + def set_log_jti(jti: str) -> None: + log_jti[0] = jti + + err: Optional[Exception] = None + try: + return _verify_impl(compact, opts, set_log_jti) + except Exception as e: + err = e + raise + finally: + if opts.on_verify_attempt is not None: + opts.on_verify_attempt(log_jti[0], err) + + +def _verify_impl(compact: str, opts: VerifyOptions, set_log_jti: Callable[[str], None]) -> ParsedECT: + header = jwt.get_unverified_header(compact) + typ = header.get("typ") or "" + # Constant-time comparison for typ + if not hmac.compare_digest(typ, ECT_TYPE): + raise ValueError("ect: invalid typ parameter") + alg = header.get("alg") + if alg in ("none", "HS256", "HS384", "HS512"): + raise ValueError("ect: prohibited algorithm") + kid = header.get("kid") + if not kid: + raise ValueError("ect: missing kid") + if not opts.resolve_key: + raise ValueError("ect: ResolveKey required") + pub = opts.resolve_key(kid) + if pub is None: + raise ValueError("ect: unknown key identifier") + + try: + claims = jwt.decode( + compact, + pub, + algorithms=["ES256"], + options={"verify_exp": False, "verify_aud": False, "verify_iat": False}, + ) + except jwt.InvalidSignatureError as e: + raise ValueError(f"ect: invalid signature: {e}") from e + except Exception as e: + raise ValueError(f"ect: verify failed: {e}") from e + + payload = Payload.from_claims(claims) + set_log_jti(payload.jti) + + validate_ext(payload.ext) + if opts.max_par_length > 0 and len(payload.par) > opts.max_par_length: + raise ValueError("ect: par exceeds max length") + if opts.validate_uuids: + if not valid_uuid(payload.jti): + raise ValueError("ect: jti must be UUID format") + if payload.wid and not valid_uuid(payload.wid): + raise ValueError("ect: wid must be UUID format when set") + if payload.inp_hash: + validate_hash_format(payload.inp_hash) + if payload.out_hash: + validate_hash_format(payload.out_hash) + + if opts.wit_subject and payload.iss != opts.wit_subject: + raise ValueError("ect: issuer does not match WIT subject") + if opts.verifier_id and not payload.contains_audience(opts.verifier_id): + raise ValueError("ect: audience does not include verifier") + + now = opts.now if opts.now is not None else int(time.time()) + if now > payload.exp: + raise ValueError("ect: token expired") + if now - payload.iat > opts.iat_max_age_sec: + raise ValueError("ect: iat too far in the past") + if payload.iat > now + opts.iat_max_future_sec: + raise ValueError("ect: iat in the future") + + # Required claims per spec: jti, exec_act, par. par may be set to [] when missing (from_claims already uses []). + if not payload.jti or not payload.exec_act: + raise ValueError("ect: missing required claims (jti, exec_act, par)") + if payload.par is None: + payload.par = [] + # If pol or pol_decision present, both must be present and valid + if payload.pol or payload.pol_decision: + if not payload.pol or not payload.pol_decision: + raise ValueError("ect: pol and pol_decision must both be present when either is set") + if not valid_pol_decision(payload.pol_decision): + raise ValueError("ect: invalid pol_decision value") + + if opts.store is not None and opts.dag is not None: + validate_dag(payload, opts.store, opts.dag) + + if opts.jti_seen is not None and opts.jti_seen(payload.jti): + raise ValueError("ect: jti already seen (replay)") + + return ParsedECT(header=header, payload=payload, raw=compact) diff --git a/refimpl/python/pyproject.toml b/refimpl/python/pyproject.toml new file mode 100644 index 0000000..421e0d1 --- /dev/null +++ b/refimpl/python/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools>=61", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "ect-refimpl" +version = "0.1.0" +description = "WIMSE Execution Context Tokens (ECT) reference implementation" +requires-python = ">=3.9" +license = "BSD-3-Clause" +dependencies = [ + "PyJWT>=2.8.0", + "cryptography>=42.0.0", +] + +[project.optional-dependencies] +dev = ["pytest>=7.0", "pytest-cov>=4.0"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] +addopts = "--cov=ect --cov-report=term-missing --cov-fail-under=90 -v" + +[tool.setuptools.packages.find] +include = ["ect*"] diff --git a/refimpl/python/testdata/valid_root_ect_payload.json b/refimpl/python/testdata/valid_root_ect_payload.json new file mode 100644 index 0000000..8105b0e --- /dev/null +++ b/refimpl/python/testdata/valid_root_ect_payload.json @@ -0,0 +1 @@ +{"iss":"spiffe://example.com/agent/clinical","sub":"spiffe://example.com/agent/clinical","aud":"spiffe://example.com/agent/safety","iat":1772064150,"exp":1772064750,"jti":"7f3a8b2c-d1e4-4f56-9a0b-c3d4e5f6a7b8","wid":"a0b1c2d3-e4f5-6789-abcd-ef0123456789","exec_act":"recommend_treatment","par":[],"pol":"clinical_reasoning_policy_v2","pol_decision":"approved"} diff --git a/refimpl/python/tests/__init__.py b/refimpl/python/tests/__init__.py new file mode 100644 index 0000000..d4839a6 --- /dev/null +++ b/refimpl/python/tests/__init__.py @@ -0,0 +1 @@ +# Tests package diff --git a/refimpl/python/tests/test_config.py b/refimpl/python/tests/test_config.py new file mode 100644 index 0000000..f144b1e --- /dev/null +++ b/refimpl/python/tests/test_config.py @@ -0,0 +1,49 @@ +"""Tests for config module.""" + +import os + +import pytest + +from ect import default_config, load_config_from_env +from ect.config import ENV_IAT_MAX_AGE_MINUTES, ENV_JTI_REPLAY_CACHE_SIZE + + +def test_default_config(): + c = default_config() + assert c.iat_max_age_sec == 900 + assert c.jti_replay_size == 0 + + +def test_load_config_from_env(): + os.environ[ENV_IAT_MAX_AGE_MINUTES] = "20" + os.environ[ENV_JTI_REPLAY_CACHE_SIZE] = "500" + try: + c = load_config_from_env() + assert c.iat_max_age_sec == 20 * 60 + assert c.jti_replay_size == 500 + finally: + os.environ.pop(ENV_IAT_MAX_AGE_MINUTES, None) + os.environ.pop(ENV_JTI_REPLAY_CACHE_SIZE, None) + + +def test_config_create_options(): + c = default_config() + opts = c.create_options("my-kid") + assert opts.key_id == "my-kid" + assert opts.default_expiry_sec == c.default_expiry_sec + + +def test_config_verify_options(): + c = default_config() + opts = c.verify_options() + assert opts.iat_max_age_sec == c.iat_max_age_sec + assert opts.dag is not None + + +def test_load_config_invalid_int(): + os.environ[ENV_IAT_MAX_AGE_MINUTES] = "bad" + try: + c = load_config_from_env() + assert c.iat_max_age_sec == 900 + finally: + os.environ.pop(ENV_IAT_MAX_AGE_MINUTES, None) diff --git a/refimpl/python/tests/test_create.py b/refimpl/python/tests/test_create.py new file mode 100644 index 0000000..f543f6f --- /dev/null +++ b/refimpl/python/tests/test_create.py @@ -0,0 +1,77 @@ +"""Tests for ECT creation and roundtrip.""" + +import json +import os +import time + +import pytest + +from ect import ( + Payload, + create, + generate_key, + CreateOptions, + verify, + VerifyOptions, + POL_DECISION_APPROVED, +) + + +def test_create_roundtrip(): + key = generate_key() + now = int(time.time()) + payload = Payload( + iss="spiffe://example.com/agent/a", + aud=["spiffe://example.com/agent/b"], + iat=now, + exp=now + 600, + jti="e4f5a6b7-c8d9-0123-ef01-234567890abc", + exec_act="review_spec", + par=[], + pol="spec_review_policy_v2", + pol_decision=POL_DECISION_APPROVED, + ) + compact = create(payload, key, CreateOptions(key_id="agent-a-key-1")) + assert compact + + def resolver(kid): + if kid == "agent-a-key-1": + return key.public_key() + return None + + opts = VerifyOptions( + verifier_id="spiffe://example.com/agent/b", + resolve_key=resolver, + now=now, + ) + parsed = verify(compact, opts) + assert parsed.payload.jti == payload.jti + assert parsed.payload.exec_act == payload.exec_act + + +def test_create_with_test_vector(): + path = os.path.join(os.path.dirname(__file__), "..", "testdata", "valid_root_ect_payload.json") + if not os.path.exists(path): + pytest.skip(f"test vector not found: {path}") + with open(path) as f: + data = json.load(f) + payload = Payload.from_claims(data) + key = generate_key() + now = int(time.time()) + payload.iat = now + payload.exp = now + 600 + + compact = create(payload, key, CreateOptions(key_id="test-kid")) + assert compact + + def resolver(kid): + if kid == "test-kid": + return key.public_key() + return None + + opts = VerifyOptions( + verifier_id=payload.aud[0], + resolve_key=resolver, + now=now, + ) + verify(compact, opts) diff --git a/refimpl/python/tests/test_create_extra.py b/refimpl/python/tests/test_create_extra.py new file mode 100644 index 0000000..27e2965 --- /dev/null +++ b/refimpl/python/tests/test_create_extra.py @@ -0,0 +1,98 @@ +"""Additional tests for create module.""" + +import time + +import pytest + +from ect import Payload, create, generate_key, CreateOptions, default_create_options, POL_DECISION_APPROVED + + +def test_default_create_options(): + opts = default_create_options() + assert opts.key_id == "" + + +def test_create_errors(): + key = generate_key() + p = Payload(iss="i", aud=["a"], iat=1, exp=2, jti="j", exec_act="e", par=[], pol="p", pol_decision=POL_DECISION_APPROVED) + with pytest.raises(ValueError, match="KeyID|required"): + create(p, key, CreateOptions(key_id="")) + with pytest.raises((ValueError, TypeError, AttributeError)): + create(None, key, CreateOptions(key_id="k")) + + +def test_create_optional_pol(): + key = generate_key() + now = int(time.time()) + p = Payload( + iss="iss", aud=["a"], iat=now, exp=now + 3600, + jti="jti-nopol", exec_act="act", par=[], + ) + compact = create(p, key, CreateOptions(key_id="kid")) + assert compact + + +def test_create_validation_errors(): + key = generate_key() + base = dict(iss="i", aud=["a"], iat=1, exp=2, jti="j", exec_act="e", par=[]) + with pytest.raises(ValueError, match="iss"): + create(Payload(**{**base, "iss": ""}), key, CreateOptions(key_id="k")) + with pytest.raises(ValueError, match="aud"): + create(Payload(**{**base, "aud": []}), key, CreateOptions(key_id="k")) + with pytest.raises(ValueError, match="jti"): + create(Payload(**{**base, "jti": ""}), key, CreateOptions(key_id="k")) + with pytest.raises(ValueError, match="exec_act"): + create(Payload(**{**base, "exec_act": ""}), key, CreateOptions(key_id="k")) + with pytest.raises(ValueError, match="pol and pol_decision"): + create(Payload(**{**base, "pol": "p", "pol_decision": ""}), key, CreateOptions(key_id="k")) + with pytest.raises(ValueError, match="pol_decision"): + create(Payload(**{**base, "pol": "p", "pol_decision": "bad"}), key, CreateOptions(key_id="k")) + + +def test_create_ext_compensation_reason_requires_required(): + key = generate_key() + p = Payload( + iss="i", aud=["a"], iat=1, exp=2, jti="j", exec_act="e", par=[], + ext={"compensation_reason": "rollback", "compensation_required": False}, + ) + with pytest.raises(ValueError, match="compensation_required"): + create(p, key, CreateOptions(key_id="k")) + + +def test_create_zero_expiry_uses_default(): + key = generate_key() + p = Payload(iss="i", aud=["a"], iat=0, exp=0, jti="j", exec_act="e", par=[]) + compact = create(p, key, CreateOptions(key_id="k", default_expiry_sec=300)) + assert compact + # create() works on a copy; decode the token to verify defaults were applied + import jwt + claims = jwt.decode(compact, options={"verify_signature": False}) + assert claims["exp"] > claims["iat"] + + +def test_create_validate_uuids_rejects_non_uuid_jti(): + key = generate_key() + now = int(time.time()) + p = Payload(iss="i", aud=["a"], iat=now, exp=now + 3600, jti="not-a-uuid", exec_act="e", par=[]) + with pytest.raises(ValueError, match="jti must be UUID"): + create(p, key, CreateOptions(key_id="k", validate_uuids=True)) + + +def test_create_max_par_length(): + key = generate_key() + now = int(time.time()) + p = Payload(iss="i", aud=["a"], iat=now, exp=now + 3600, jti="550e8400-e29b-41d4-a716-446655440000", exec_act="e", par=["p1", "p2"]) + with pytest.raises(ValueError, match="par exceeds max length"): + create(p, key, CreateOptions(key_id="k", max_par_length=1)) + + +def test_create_ext_size_rejected(): + from ect.validate import EXT_MAX_SIZE + key = generate_key() + now = int(time.time()) + p = Payload( + iss="i", aud=["a"], iat=now, exp=now + 3600, jti="550e8400-e29b-41d4-a716-446655440000", exec_act="e", par=[], + ext={"x": "y" * (EXT_MAX_SIZE - 5)}, + ) + with pytest.raises(ValueError, match="ext exceeds max size"): + create(p, key, CreateOptions(key_id="k")) diff --git a/refimpl/python/tests/test_dag.py b/refimpl/python/tests/test_dag.py new file mode 100644 index 0000000..052ea0a --- /dev/null +++ b/refimpl/python/tests/test_dag.py @@ -0,0 +1,123 @@ +"""Tests for DAG validation.""" + +import time + +import pytest + +from ect import Payload, MemoryLedger, validate_dag, default_dag_config, POL_DECISION_APPROVED + + +def test_validate_dag_root(): + store = MemoryLedger() + payload = Payload( + iss="", + aud=[], + iat=0, + exp=0, + jti="jti-001", + exec_act="", + par=[], + pol="", + pol_decision=POL_DECISION_APPROVED, + wid="wf-1", + ) + validate_dag(payload, store, default_dag_config()) + + +def test_validate_dag_duplicate_jti(): + store = MemoryLedger() + p = Payload( + iss="x", + aud=["y"], + iat=0, + exp=0, + jti="jti-001", + exec_act="a", + par=[], + pol="p", + pol_decision=POL_DECISION_APPROVED, + wid="wf-1", + ) + store.append("dummy-jws", p) + payload = Payload( + iss="", + aud=[], + iat=0, + exp=0, + jti="jti-001", + exec_act="", + par=[], + pol="", + pol_decision=POL_DECISION_APPROVED, + wid="wf-1", + ) + with pytest.raises(ValueError, match="task ID.*already exists"): + validate_dag(payload, store, default_dag_config()) + + +def test_validate_dag_parent_exists(): + store = MemoryLedger() + now = int(time.time()) + p = Payload( + iss="x", + aud=["y"], + iat=now - 60, + exp=now + 600, + jti="jti-001", + exec_act="a", + par=[], + pol="p", + pol_decision=POL_DECISION_APPROVED, + wid="wf-1", + ) + store.append("jws1", p) + payload = Payload( + iss="", + aud=[], + iat=now, + exp=now + 600, + jti="jti-002", + exec_act="b", + par=["jti-001"], + pol="p", + pol_decision=POL_DECISION_APPROVED, + wid="wf-1", + ) + validate_dag(payload, store, default_dag_config()) + + +def test_validate_dag_parent_not_found(): + store = MemoryLedger() + now = int(time.time()) + payload = Payload( + iss="", + aud=[], + iat=now, + exp=now + 600, + jti="jti-002", + exec_act="", + par=["jti-missing"], + pol="", + pol_decision=POL_DECISION_APPROVED, + ) + with pytest.raises(ValueError, match="parent task not found"): + validate_dag(payload, store, default_dag_config()) + + +def test_validate_dag_parent_policy_rejected_requires_compensation(): + from ect import POL_DECISION_REJECTED + store = MemoryLedger() + now = int(time.time()) + p = Payload( + iss="x", aud=["y"], iat=now - 60, exp=now + 600, + jti="jti-rej", exec_act="a", par=[], pol="p", pol_decision=POL_DECISION_REJECTED, wid="wf-1", + ) + store.append("jws1", p) + payload = Payload( + iss="", aud=[], iat=now, exp=now + 600, + jti="jti-child", exec_act="b", par=["jti-rej"], pol="p", pol_decision=POL_DECISION_APPROVED, wid="wf-1", + ) + with pytest.raises(ValueError, match="compensation"): + validate_dag(payload, store, default_dag_config()) + payload.ext = {"compensation_required": True} + validate_dag(payload, store, default_dag_config()) diff --git a/refimpl/python/tests/test_jti_cache.py b/refimpl/python/tests/test_jti_cache.py new file mode 100644 index 0000000..dca9bf5 --- /dev/null +++ b/refimpl/python/tests/test_jti_cache.py @@ -0,0 +1,40 @@ +"""Tests for JTI replay cache.""" + +import time + +import pytest + +from ect import new_jti_cache + + +def test_jti_cache_seen_and_add(): + cache = new_jti_cache(10, 60) + assert cache.seen("jti-1") is False + cache.add("jti-1") + assert cache.seen("jti-1") is True + assert cache.seen("jti-2") is False + cache.add("jti-2") + assert cache.seen("jti-2") is True + + +def test_jti_cache_expiry(): + cache = new_jti_cache(10, 1) # 1 second TTL + cache.add("jti-1") + assert cache.seen("jti-1") is True + time.sleep(1.1) + assert cache.seen("jti-1") is False + + +def test_jti_cache_max_size_eviction(): + cache = new_jti_cache(2, 60) + cache.add("jti-1") + cache.add("jti-2") + cache.add("jti-3") + assert cache.seen("jti-3") is True + + +def test_jti_cache_add_when_already_present(): + cache = new_jti_cache(2, 60) + cache.add("jti-1") + cache.add("jti-1") + assert cache.seen("jti-1") is True diff --git a/refimpl/python/tests/test_ledger_extra.py b/refimpl/python/tests/test_ledger_extra.py new file mode 100644 index 0000000..db02d4b --- /dev/null +++ b/refimpl/python/tests/test_ledger_extra.py @@ -0,0 +1,38 @@ +"""Additional tests for ledger module.""" + +import time + +import pytest + +from ect import Payload, MemoryLedger, ErrTaskIDExists, POL_DECISION_APPROVED + + +def test_ledger_append_and_get(): + m = MemoryLedger() + p = Payload(iss="i", aud=["a"], iat=1, exp=2, jti="j1", exec_act="act", par=[]) + seq = m.append("jws1", p) + assert seq == 1 + assert m.get_by_tid("j1").jti == "j1" + + +def test_ledger_err_task_id_exists(): + m = MemoryLedger() + p = Payload(iss="i", aud=["a"], iat=1, exp=2, jti="j-dup", exec_act="e", par=[]) + m.append("jws1", p) + with pytest.raises(ErrTaskIDExists): + m.append("jws2", p) + + +def test_ledger_contains_wid(): + m = MemoryLedger() + p = Payload(iss="i", aud=["a"], iat=1, exp=2, jti="j1", exec_act="e", par=[], wid="wf1") + m.append("jws", p) + assert m.contains("j1", "") is True + assert m.contains("j1", "wf1") is True + assert m.contains("j1", "wf2") is False + + +def test_ledger_append_none(): + m = MemoryLedger() + seq = m.append("jws", None) + assert seq == 0 diff --git a/refimpl/python/tests/test_types_extra.py b/refimpl/python/tests/test_types_extra.py new file mode 100644 index 0000000..9d2ca2a --- /dev/null +++ b/refimpl/python/tests/test_types_extra.py @@ -0,0 +1,63 @@ +"""Additional tests for types module.""" + +import pytest + +from ect import Payload, POL_DECISION_APPROVED +from ect.types import valid_pol_decision + + +def test_valid_pol_decision(): + assert valid_pol_decision("approved") is True + assert valid_pol_decision("rejected") is True + assert valid_pol_decision("pending_human_review") is True + assert valid_pol_decision("invalid") is False + + +def test_payload_contains_audience(): + p = Payload(iss="", aud=["a", "b"], iat=0, exp=0, jti="", exec_act="", par=[]) + assert p.contains_audience("a") is True + assert p.contains_audience("c") is False + + +def test_payload_compensation_required(): + p = Payload(iss="", aud=[], iat=0, exp=0, jti="", exec_act="", par=[]) + assert p.compensation_required() is False + p.ext = {"compensation_required": True} + assert p.compensation_required() is True + + +def test_payload_has_policy_claims(): + p = Payload(iss="", aud=[], iat=0, exp=0, jti="", exec_act="", par=[], pol="p", pol_decision=POL_DECISION_APPROVED) + assert p.has_policy_claims() is True + p.pol = "" + assert p.has_policy_claims() is False + + +def test_payload_to_claims_optional(): + p = Payload(iss="i", aud=["a"], iat=1, exp=2, jti="j", exec_act="e", par=[], wid="wf") + claims = p.to_claims() + assert claims["wid"] == "wf" + assert "ext" not in claims or not claims.get("ext") + + +def test_payload_from_claims_aud_string(): + claims = {"iss": "i", "aud": "single", "iat": 1, "exp": 2, "jti": "j", "exec_act": "e", "par": []} + p = Payload.from_claims(claims) + assert p.aud == ["single"] + + +def test_payload_to_claims_all_optional(): + p = Payload( + iss="i", aud=["a"], iat=1, exp=2, jti="j", exec_act="e", par=[], + sub="s", wid="w", pol="p", pol_decision="approved", pol_enforcer="e", + pol_timestamp=1, inp_hash="h", out_hash="o", inp_classification="c", + ) + claims = p.to_claims() + assert claims["sub"] == "s" + assert claims["wid"] == "w" + assert claims["pol"] == "p" + assert claims["pol_enforcer"] == "e" + assert claims["pol_timestamp"] == 1 + assert claims["inp_hash"] == "h" + assert claims["out_hash"] == "o" + assert claims["inp_classification"] == "c" diff --git a/refimpl/python/tests/test_validate.py b/refimpl/python/tests/test_validate.py new file mode 100644 index 0000000..691b6bd --- /dev/null +++ b/refimpl/python/tests/test_validate.py @@ -0,0 +1,63 @@ +"""Tests for validate module.""" + +import json +import pytest + +from ect.validate import ( + EXT_MAX_DEPTH, + EXT_MAX_SIZE, + validate_ext, + validate_hash_format, + valid_uuid, +) + + +def test_valid_uuid(): + assert valid_uuid("550e8400-e29b-41d4-a716-446655440000") is True + assert valid_uuid("00000000-0000-0000-0000-000000000000") is True + assert valid_uuid("") is False + assert valid_uuid("not-a-uuid") is False + assert valid_uuid("550e8400e29b41d4a716446655440000") is False # no dashes + + +def test_validate_ext_none(): + validate_ext(None) + validate_ext({}) + + +def test_validate_ext_size(): + # Serialized JSON must exceed EXT_MAX_SIZE (4096) bytes + big = {"x": "y" * (EXT_MAX_SIZE - 2)} # "{\"x\":\"...\"}" + payload + raw = json.dumps(big) + assert len(raw.encode("utf-8")) > EXT_MAX_SIZE + with pytest.raises(ValueError, match="max size"): + validate_ext(big) + + +def test_validate_ext_depth(): + deep = {"a": 1} + for _ in range(EXT_MAX_DEPTH): + deep = {"n": deep} + with pytest.raises(ValueError, match="depth"): + validate_ext(deep) + + +def test_validate_hash_format_empty(): + validate_hash_format("") + + +def test_validate_hash_format_ok(): + # sha-256:base64url (minimal valid) + validate_hash_format("sha-256:YQ") + validate_hash_format("sha-384:YQ") + validate_hash_format("sha-512:YQ") + + +def test_validate_hash_format_bad(): + with pytest.raises(ValueError, match="algorithm:base64url|inp_hash"): + validate_hash_format("md5:abc") + with pytest.raises(ValueError, match="algorithm:base64url|inp_hash"): + validate_hash_format("no-colon") + # Invalid base64 that triggers decode error (e.g. binary) + with pytest.raises(ValueError, match="algorithm:base64url|inp_hash"): + validate_hash_format("sha-256:YQ\x00") # null in payload diff --git a/refimpl/python/tests/test_verify.py b/refimpl/python/tests/test_verify.py new file mode 100644 index 0000000..972f4fd --- /dev/null +++ b/refimpl/python/tests/test_verify.py @@ -0,0 +1,197 @@ +"""Tests for verify module.""" + +import time + +import pytest + +from ect import ( + Payload, + create, + generate_key, + CreateOptions, + parse, + verify, + VerifyOptions, + default_verify_options, + POL_DECISION_APPROVED, +) + + +def test_parse(): + key = generate_key() + now = int(time.time()) + p = Payload( + iss="iss", aud=["a"], iat=now, exp=now + 3600, + jti="jti-parse", exec_act="act", par=[], pol="p", pol_decision=POL_DECISION_APPROVED, + ) + compact = create(p, key, CreateOptions(key_id="kid")) + parsed = parse(compact) + assert parsed.payload.jti == "jti-parse" + assert parsed.raw == compact + + +def test_default_verify_options(): + opts = default_verify_options() + assert opts.dag is not None + assert opts.iat_max_age_sec == 900 + + +def test_verify_expired(): + key = generate_key() + now = int(time.time()) + p = Payload( + iss="iss", aud=["v"], iat=now - 3600, exp=now - 60, + jti="jti-exp", exec_act="act", par=[], pol="p", pol_decision=POL_DECISION_APPROVED, + ) + compact = create(p, key, CreateOptions(key_id="kid")) + resolver = lambda kid: key.public_key() if kid == "kid" else None + with pytest.raises(ValueError, match="expired"): + verify(compact, VerifyOptions(verifier_id="v", resolve_key=resolver, now=now)) + + +def test_verify_replay(): + key = generate_key() + now = int(time.time()) + p = Payload( + iss="iss", aud=["v"], iat=now, exp=now + 3600, + jti="jti-replay", exec_act="act", par=[], pol="p", pol_decision=POL_DECISION_APPROVED, + ) + compact = create(p, key, CreateOptions(key_id="kid")) + resolver = lambda kid: key.public_key() if kid == "kid" else None + with pytest.raises(ValueError, match="replay"): + verify(compact, VerifyOptions( + verifier_id="v", resolve_key=resolver, now=now, + jti_seen=lambda j: j == "jti-replay", + )) + + +def test_verify_invalid_typ(): + import jwt as jwt_lib + with pytest.raises((ValueError, jwt_lib.exceptions.DecodeError)): + verify("not-a-jws", VerifyOptions()) + + +def test_verify_audience_mismatch(): + key = generate_key() + now = int(time.time()) + p = Payload( + iss="iss", aud=["other"], iat=now, exp=now + 3600, + jti="jti-a", exec_act="act", par=[], pol="p", pol_decision=POL_DECISION_APPROVED, + ) + compact = create(p, key, CreateOptions(key_id="kid")) + resolver = lambda kid: key.public_key() if kid == "kid" else None + with pytest.raises(ValueError, match="audience"): + verify(compact, VerifyOptions(verifier_id="verifier", resolve_key=resolver, now=now)) + + +def test_verify_wit_subject_mismatch(): + key = generate_key() + now = int(time.time()) + p = Payload( + iss="wrong-iss", aud=["v"], iat=now, exp=now + 3600, + jti="jti-w", exec_act="act", par=[], pol="p", pol_decision=POL_DECISION_APPROVED, + ) + compact = create(p, key, CreateOptions(key_id="kid")) + resolver = lambda kid: key.public_key() if kid == "kid" else None + with pytest.raises(ValueError, match="WIT subject"): + verify(compact, VerifyOptions( + verifier_id="v", resolve_key=resolver, now=now, wit_subject="correct-iss", + )) + + +def test_verify_iat_too_old(): + key = generate_key() + now = int(time.time()) + p = Payload( + iss="iss", aud=["v"], iat=now - 2000, exp=now + 3600, + jti="jti-old", exec_act="act", par=[], pol="p", pol_decision=POL_DECISION_APPROVED, + ) + compact = create(p, key, CreateOptions(key_id="kid")) + resolver = lambda kid: key.public_key() if kid == "kid" else None + with pytest.raises(ValueError, match="iat"): + verify(compact, VerifyOptions( + verifier_id="v", resolve_key=resolver, now=now, iat_max_age_sec=900, + )) + + +def test_verify_unknown_key(): + key = generate_key() + now = int(time.time()) + p = Payload( + iss="iss", aud=["v"], iat=now, exp=now + 3600, + jti="jti-k", exec_act="act", par=[], pol="p", pol_decision=POL_DECISION_APPROVED, + ) + compact = create(p, key, CreateOptions(key_id="kid")) + resolver = lambda kid: None # unknown key + with pytest.raises(ValueError, match="unknown key"): + verify(compact, VerifyOptions(verifier_id="v", resolve_key=resolver, now=now)) + + +def test_verify_resolve_key_required(): + key = generate_key() + now = int(time.time()) + p = Payload( + iss="iss", aud=["v"], iat=now, exp=now + 3600, + jti="jti-r", exec_act="act", par=[], pol="p", pol_decision=POL_DECISION_APPROVED, + ) + compact = create(p, key, CreateOptions(key_id="kid")) + with pytest.raises(ValueError, match="ResolveKey"): + verify(compact, VerifyOptions(verifier_id="v", resolve_key=None)) + + +def test_verify_with_dag(): + from ect import MemoryLedger + key = generate_key() + ledger = MemoryLedger() + now = int(time.time()) + root = Payload( + iss="iss", aud=["v"], iat=now, exp=now + 3600, + jti="jti-root", exec_act="act", par=[], pol="p", pol_decision=POL_DECISION_APPROVED, + ) + compact_root = create(root, key, CreateOptions(key_id="kid")) + resolver = lambda kid: key.public_key() if kid == "kid" else None + opts = VerifyOptions(verifier_id="v", resolve_key=resolver, store=ledger, now=now) + parsed = verify(compact_root, opts) + ledger.append(compact_root, parsed.payload) + child = Payload( + iss="iss", aud=["v"], iat=now + 1, exp=now + 3600, + jti="jti-child", exec_act="act2", par=["jti-root"], pol="p", pol_decision=POL_DECISION_APPROVED, + ) + compact_child = create(child, key, CreateOptions(key_id="kid")) + parsed2 = verify(compact_child, opts) + assert parsed2.payload.jti == "jti-child" + + +def test_on_verify_attempt_callback(): + """Observability: on_verify_attempt is called with jti and error (or None).""" + key = generate_key() + now = int(time.time()) + p = Payload(iss="i", aud=["v"], iat=now, exp=now + 3600, jti="jti-obs", exec_act="a", par=[]) + compact = create(p, key, CreateOptions(key_id="kid")) + resolver = lambda k: key.public_key() if k == "kid" else None + seen = [] + def hook(jti, err): + seen.append((jti, err)) + opts = VerifyOptions(verifier_id="v", resolve_key=resolver, on_verify_attempt=hook) + result = verify(compact, opts) + assert result.payload.jti == "jti-obs" + assert len(seen) == 1 + assert seen[0][0] == "jti-obs" + assert seen[0][1] is None + + +def test_on_verify_attempt_called_on_failure(): + key = generate_key() + now = int(time.time()) + p = Payload(iss="i", aud=["v"], iat=now, exp=now - 1, jti="jti-fail", exec_act="a", par=[]) + compact = create(p, key, CreateOptions(key_id="kid")) + resolver = lambda k: key.public_key() if k == "kid" else None + seen = [] + opts = VerifyOptions(verifier_id="v", resolve_key=resolver, now=now, on_verify_attempt=lambda jti, err: seen.append((jti, err))) + with pytest.raises(ValueError, match="expired"): + verify(compact, opts) + assert len(seen) == 1 + assert seen[0][0] == "jti-fail" + assert seen[0][1] is not None + + diff --git a/refimpl/python/uv.lock b/refimpl/python/uv.lock new file mode 100644 index 0000000..c9af4b0 --- /dev/null +++ b/refimpl/python/uv.lock @@ -0,0 +1,653 @@ +version = 1 +revision = 2 +requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version < '3.10'", +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", version = "2.23", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and implementation_name != 'PyPy'" }, + { name = "pycparser", version = "3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cc/08ed5a43f2996a16b462f64a7055c6e962803534924b9b2f1371d8c00b7b/cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf", size = 184288, upload-time = "2025-09-08T23:23:48.404Z" }, + { url = "https://files.pythonhosted.org/packages/3d/de/38d9726324e127f727b4ecc376bc85e505bfe61ef130eaf3f290c6847dd4/cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7", size = 180509, upload-time = "2025-09-08T23:23:49.73Z" }, + { url = "https://files.pythonhosted.org/packages/9b/13/c92e36358fbcc39cf0962e83223c9522154ee8630e1df7c0b3a39a8124e2/cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c", size = 208813, upload-time = "2025-09-08T23:23:51.263Z" }, + { url = "https://files.pythonhosted.org/packages/15/12/a7a79bd0df4c3bff744b2d7e52cc1b68d5e7e427b384252c42366dc1ecbc/cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165", size = 216498, upload-time = "2025-09-08T23:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/5c51c1c7600bdd7ed9a24a203ec255dccdd0ebf4527f7b922a0bde2fb6ed/cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534", size = 203243, upload-time = "2025-09-08T23:23:53.836Z" }, + { url = "https://files.pythonhosted.org/packages/32/f2/81b63e288295928739d715d00952c8c6034cb6c6a516b17d37e0c8be5600/cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f", size = 203158, upload-time = "2025-09-08T23:23:55.169Z" }, + { url = "https://files.pythonhosted.org/packages/1f/74/cc4096ce66f5939042ae094e2e96f53426a979864aa1f96a621ad128be27/cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63", size = 216548, upload-time = "2025-09-08T23:23:56.506Z" }, + { url = "https://files.pythonhosted.org/packages/e8/be/f6424d1dc46b1091ffcc8964fa7c0ab0cd36839dd2761b49c90481a6ba1b/cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2", size = 218897, upload-time = "2025-09-08T23:23:57.825Z" }, + { url = "https://files.pythonhosted.org/packages/f7/e0/dda537c2309817edf60109e39265f24f24aa7f050767e22c98c53fe7f48b/cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65", size = 211249, upload-time = "2025-09-08T23:23:59.139Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e7/7c769804eb75e4c4b35e658dba01de1640a351a9653c3d49ca89d16ccc91/cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322", size = 218041, upload-time = "2025-09-08T23:24:00.496Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d9/6218d78f920dcd7507fc16a766b5ef8f3b913cc7aa938e7fc80b9978d089/cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a", size = 172138, upload-time = "2025-09-08T23:24:01.7Z" }, + { url = "https://files.pythonhosted.org/packages/54/8f/a1e836f82d8e32a97e6b29cc8f641779181ac7363734f12df27db803ebda/cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9", size = 182794, upload-time = "2025-09-08T23:24:02.943Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.10.7" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6c/3a3f7a46888e69d18abe3ccc6fe4cb16cccb1e6a2f99698931dafca489e6/coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a", size = 217987, upload-time = "2025-09-21T20:00:57.218Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/952d30f180b1a916c11a56f5c22d3535e943aa22430e9e3322447e520e1c/coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5", size = 218388, upload-time = "2025-09-21T20:01:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/50/2b/9e0cf8ded1e114bcd8b2fd42792b57f1c4e9e4ea1824cde2af93a67305be/coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17", size = 245148, upload-time = "2025-09-21T20:01:01.768Z" }, + { url = "https://files.pythonhosted.org/packages/19/20/d0384ac06a6f908783d9b6aa6135e41b093971499ec488e47279f5b846e6/coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b", size = 246958, upload-time = "2025-09-21T20:01:03.355Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/5c283cff3d41285f8eab897651585db908a909c572bdc014bcfaf8a8b6ae/coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87", size = 248819, upload-time = "2025-09-21T20:01:04.968Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/02eb98fdc5ff79f423e990d877693e5310ae1eab6cb20ae0b0b9ac45b23b/coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e", size = 245754, upload-time = "2025-09-21T20:01:06.321Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bc/25c83bcf3ad141b32cd7dc45485ef3c01a776ca3aa8ef0a93e77e8b5bc43/coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e", size = 246860, upload-time = "2025-09-21T20:01:07.605Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/95574702888b58c0928a6e982038c596f9c34d52c5e5107f1eef729399b5/coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df", size = 244877, upload-time = "2025-09-21T20:01:08.829Z" }, + { url = "https://files.pythonhosted.org/packages/47/b6/40095c185f235e085df0e0b158f6bd68cc6e1d80ba6c7721dc81d97ec318/coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0", size = 245108, upload-time = "2025-09-21T20:01:10.527Z" }, + { url = "https://files.pythonhosted.org/packages/c8/50/4aea0556da7a4b93ec9168420d170b55e2eb50ae21b25062513d020c6861/coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13", size = 245752, upload-time = "2025-09-21T20:01:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/6a/28/ea1a84a60828177ae3b100cb6723838523369a44ec5742313ed7db3da160/coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b", size = 220497, upload-time = "2025-09-21T20:01:13.459Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1a/a81d46bbeb3c3fd97b9602ebaa411e076219a150489bcc2c025f151bd52d/coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807", size = 221392, upload-time = "2025-09-21T20:01:14.722Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5d/c1a17867b0456f2e9ce2d8d4708a4c3a089947d0bec9c66cdf60c9e7739f/coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59", size = 218102, upload-time = "2025-09-21T20:01:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/54/f0/514dcf4b4e3698b9a9077f084429681bf3aad2b4a72578f89d7f643eb506/coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a", size = 218505, upload-time = "2025-09-21T20:01:17.788Z" }, + { url = "https://files.pythonhosted.org/packages/20/f6/9626b81d17e2a4b25c63ac1b425ff307ecdeef03d67c9a147673ae40dc36/coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699", size = 248898, upload-time = "2025-09-21T20:01:19.488Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ef/bd8e719c2f7417ba03239052e099b76ea1130ac0cbb183ee1fcaa58aaff3/coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d", size = 250831, upload-time = "2025-09-21T20:01:20.817Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b6/bf054de41ec948b151ae2b79a55c107f5760979538f5fb80c195f2517718/coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e", size = 252937, upload-time = "2025-09-21T20:01:22.171Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e5/3860756aa6f9318227443c6ce4ed7bf9e70bb7f1447a0353f45ac5c7974b/coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23", size = 249021, upload-time = "2025-09-21T20:01:23.907Z" }, + { url = "https://files.pythonhosted.org/packages/26/0f/bd08bd042854f7fd07b45808927ebcce99a7ed0f2f412d11629883517ac2/coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab", size = 250626, upload-time = "2025-09-21T20:01:25.721Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a7/4777b14de4abcc2e80c6b1d430f5d51eb18ed1d75fca56cbce5f2db9b36e/coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82", size = 248682, upload-time = "2025-09-21T20:01:27.105Z" }, + { url = "https://files.pythonhosted.org/packages/34/72/17d082b00b53cd45679bad682fac058b87f011fd8b9fe31d77f5f8d3a4e4/coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2", size = 248402, upload-time = "2025-09-21T20:01:28.629Z" }, + { url = "https://files.pythonhosted.org/packages/81/7a/92367572eb5bdd6a84bfa278cc7e97db192f9f45b28c94a9ca1a921c3577/coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61", size = 249320, upload-time = "2025-09-21T20:01:30.004Z" }, + { url = "https://files.pythonhosted.org/packages/2f/88/a23cc185f6a805dfc4fdf14a94016835eeb85e22ac3a0e66d5e89acd6462/coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14", size = 220536, upload-time = "2025-09-21T20:01:32.184Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ef/0b510a399dfca17cec7bc2f05ad8bd78cf55f15c8bc9a73ab20c5c913c2e/coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2", size = 221425, upload-time = "2025-09-21T20:01:33.557Z" }, + { url = "https://files.pythonhosted.org/packages/51/7f/023657f301a276e4ba1850f82749bc136f5a7e8768060c2e5d9744a22951/coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a", size = 220103, upload-time = "2025-09-21T20:01:34.929Z" }, + { url = "https://files.pythonhosted.org/packages/13/e4/eb12450f71b542a53972d19117ea5a5cea1cab3ac9e31b0b5d498df1bd5a/coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417", size = 218290, upload-time = "2025-09-21T20:01:36.455Z" }, + { url = "https://files.pythonhosted.org/packages/37/66/593f9be12fc19fb36711f19a5371af79a718537204d16ea1d36f16bd78d2/coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973", size = 218515, upload-time = "2025-09-21T20:01:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/4c49f7ae09cafdacc73fbc30949ffe77359635c168f4e9ff33c9ebb07838/coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c", size = 250020, upload-time = "2025-09-21T20:01:39.617Z" }, + { url = "https://files.pythonhosted.org/packages/a6/90/a64aaacab3b37a17aaedd83e8000142561a29eb262cede42d94a67f7556b/coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7", size = 252769, upload-time = "2025-09-21T20:01:41.341Z" }, + { url = "https://files.pythonhosted.org/packages/98/2e/2dda59afd6103b342e096f246ebc5f87a3363b5412609946c120f4e7750d/coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6", size = 253901, upload-time = "2025-09-21T20:01:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/dc/8d8119c9051d50f3119bb4a75f29f1e4a6ab9415cd1fa8bf22fcc3fb3b5f/coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59", size = 250413, upload-time = "2025-09-21T20:01:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/98/b3/edaff9c5d79ee4d4b6d3fe046f2b1d799850425695b789d491a64225d493/coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b", size = 251820, upload-time = "2025-09-21T20:01:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/11/25/9a0728564bb05863f7e513e5a594fe5ffef091b325437f5430e8cfb0d530/coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a", size = 249941, upload-time = "2025-09-21T20:01:47.296Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fd/ca2650443bfbef5b0e74373aac4df67b08180d2f184b482c41499668e258/coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb", size = 249519, upload-time = "2025-09-21T20:01:48.73Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/f692f125fb4299b6f963b0745124998ebb8e73ecdfce4ceceb06a8c6bec5/coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1", size = 251375, upload-time = "2025-09-21T20:01:50.529Z" }, + { url = "https://files.pythonhosted.org/packages/5e/75/61b9bbd6c7d24d896bfeec57acba78e0f8deac68e6baf2d4804f7aae1f88/coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256", size = 220699, upload-time = "2025-09-21T20:01:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f3/3bf7905288b45b075918d372498f1cf845b5b579b723c8fd17168018d5f5/coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba", size = 221512, upload-time = "2025-09-21T20:01:53.481Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/3e32dbe933979d05cf2dac5e697c8599cfe038aaf51223ab901e208d5a62/coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf", size = 220147, upload-time = "2025-09-21T20:01:55.2Z" }, + { url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320, upload-time = "2025-09-21T20:01:56.629Z" }, + { url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575, upload-time = "2025-09-21T20:01:58.203Z" }, + { url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568, upload-time = "2025-09-21T20:01:59.748Z" }, + { url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174, upload-time = "2025-09-21T20:02:01.192Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447, upload-time = "2025-09-21T20:02:02.701Z" }, + { url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779, upload-time = "2025-09-21T20:02:04.185Z" }, + { url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604, upload-time = "2025-09-21T20:02:06.034Z" }, + { url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497, upload-time = "2025-09-21T20:02:07.619Z" }, + { url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350, upload-time = "2025-09-21T20:02:10.34Z" }, + { url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111, upload-time = "2025-09-21T20:02:12.122Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746, upload-time = "2025-09-21T20:02:13.919Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541, upload-time = "2025-09-21T20:02:15.57Z" }, + { url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170, upload-time = "2025-09-21T20:02:17.395Z" }, + { url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029, upload-time = "2025-09-21T20:02:18.936Z" }, + { url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259, upload-time = "2025-09-21T20:02:20.44Z" }, + { url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592, upload-time = "2025-09-21T20:02:22.313Z" }, + { url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768, upload-time = "2025-09-21T20:02:24.287Z" }, + { url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995, upload-time = "2025-09-21T20:02:26.133Z" }, + { url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546, upload-time = "2025-09-21T20:02:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544, upload-time = "2025-09-21T20:02:29.216Z" }, + { url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308, upload-time = "2025-09-21T20:02:31.226Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920, upload-time = "2025-09-21T20:02:32.823Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434, upload-time = "2025-09-21T20:02:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/5844ab4ca6a4dd97a1850e030a15ec7d292b5c5cb93082979225126e35dd/coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520", size = 218302, upload-time = "2025-09-21T20:02:42.527Z" }, + { url = "https://files.pythonhosted.org/packages/f0/89/673f6514b0961d1f0e20ddc242e9342f6da21eaba3489901b565c0689f34/coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32", size = 218578, upload-time = "2025-09-21T20:02:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/05/e8/261cae479e85232828fb17ad536765c88dd818c8470aca690b0ac6feeaa3/coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f", size = 249629, upload-time = "2025-09-21T20:02:46.503Z" }, + { url = "https://files.pythonhosted.org/packages/82/62/14ed6546d0207e6eda876434e3e8475a3e9adbe32110ce896c9e0c06bb9a/coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a", size = 252162, upload-time = "2025-09-21T20:02:48.689Z" }, + { url = "https://files.pythonhosted.org/packages/ff/49/07f00db9ac6478e4358165a08fb41b469a1b053212e8a00cb02f0d27a05f/coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360", size = 253517, upload-time = "2025-09-21T20:02:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/a2/59/c5201c62dbf165dfbc91460f6dbbaa85a8b82cfa6131ac45d6c1bfb52deb/coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69", size = 249632, upload-time = "2025-09-21T20:02:51.971Z" }, + { url = "https://files.pythonhosted.org/packages/07/ae/5920097195291a51fb00b3a70b9bbd2edbfe3c84876a1762bd1ef1565ebc/coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14", size = 251520, upload-time = "2025-09-21T20:02:53.858Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455, upload-time = "2025-09-21T20:02:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287, upload-time = "2025-09-21T20:02:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946, upload-time = "2025-09-21T20:02:59.431Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009, upload-time = "2025-09-21T20:03:01.324Z" }, + { url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804, upload-time = "2025-09-21T20:03:03.4Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384, upload-time = "2025-09-21T20:03:05.111Z" }, + { url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047, upload-time = "2025-09-21T20:03:06.795Z" }, + { url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266, upload-time = "2025-09-21T20:03:08.495Z" }, + { url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767, upload-time = "2025-09-21T20:03:10.172Z" }, + { url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931, upload-time = "2025-09-21T20:03:11.861Z" }, + { url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186, upload-time = "2025-09-21T20:03:13.539Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470, upload-time = "2025-09-21T20:03:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626, upload-time = "2025-09-21T20:03:17.673Z" }, + { url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386, upload-time = "2025-09-21T20:03:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852, upload-time = "2025-09-21T20:03:21.007Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534, upload-time = "2025-09-21T20:03:23.12Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784, upload-time = "2025-09-21T20:03:24.769Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905, upload-time = "2025-09-21T20:03:26.93Z" }, + { url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922, upload-time = "2025-09-21T20:03:28.672Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/d1c25053764b4c42eb294aae92ab617d2e4f803397f9c7c8295caa77a260/coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3", size = 217978, upload-time = "2025-09-21T20:03:30.362Z" }, + { url = "https://files.pythonhosted.org/packages/52/2f/b9f9daa39b80ece0b9548bbb723381e29bc664822d9a12c2135f8922c22b/coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c", size = 218370, upload-time = "2025-09-21T20:03:32.147Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6e/30d006c3b469e58449650642383dddf1c8fb63d44fdf92994bfd46570695/coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396", size = 244802, upload-time = "2025-09-21T20:03:33.919Z" }, + { url = "https://files.pythonhosted.org/packages/b0/49/8a070782ce7e6b94ff6a0b6d7c65ba6bc3091d92a92cef4cd4eb0767965c/coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40", size = 246625, upload-time = "2025-09-21T20:03:36.09Z" }, + { url = "https://files.pythonhosted.org/packages/6a/92/1c1c5a9e8677ce56d42b97bdaca337b2d4d9ebe703d8c174ede52dbabd5f/coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594", size = 248399, upload-time = "2025-09-21T20:03:38.342Z" }, + { url = "https://files.pythonhosted.org/packages/c0/54/b140edee7257e815de7426d5d9846b58505dffc29795fff2dfb7f8a1c5a0/coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a", size = 245142, upload-time = "2025-09-21T20:03:40.591Z" }, + { url = "https://files.pythonhosted.org/packages/e4/9e/6d6b8295940b118e8b7083b29226c71f6154f7ff41e9ca431f03de2eac0d/coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b", size = 246284, upload-time = "2025-09-21T20:03:42.355Z" }, + { url = "https://files.pythonhosted.org/packages/db/e5/5e957ca747d43dbe4d9714358375c7546cb3cb533007b6813fc20fce37ad/coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3", size = 244353, upload-time = "2025-09-21T20:03:44.218Z" }, + { url = "https://files.pythonhosted.org/packages/9a/45/540fc5cc92536a1b783b7ef99450bd55a4b3af234aae35a18a339973ce30/coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0", size = 244430, upload-time = "2025-09-21T20:03:46.065Z" }, + { url = "https://files.pythonhosted.org/packages/75/0b/8287b2e5b38c8fe15d7e3398849bb58d382aedc0864ea0fa1820e8630491/coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f", size = 245311, upload-time = "2025-09-21T20:03:48.19Z" }, + { url = "https://files.pythonhosted.org/packages/0c/1d/29724999984740f0c86d03e6420b942439bf5bd7f54d4382cae386a9d1e9/coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431", size = 220500, upload-time = "2025-09-21T20:03:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/43/11/4b1e6b129943f905ca54c339f343877b55b365ae2558806c1be4f7476ed5/coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07", size = 221408, upload-time = "2025-09-21T20:03:51.803Z" }, + { url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version < '3.10'" }, +] + +[[package]] +name = "coverage" +version = "7.13.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/d4/7827d9ffa34d5d4d752eec907022aa417120936282fc488306f5da08c292/coverage-7.13.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fc31c787a84f8cd6027eba44010517020e0d18487064cd3d8968941856d1415", size = 219152, upload-time = "2026-02-09T12:56:11.974Z" }, + { url = "https://files.pythonhosted.org/packages/35/b0/d69df26607c64043292644dbb9dc54b0856fabaa2cbb1eeee3331cc9e280/coverage-7.13.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a32ebc02a1805adf637fc8dec324b5cdacd2e493515424f70ee33799573d661b", size = 219667, upload-time = "2026-02-09T12:56:13.33Z" }, + { url = "https://files.pythonhosted.org/packages/82/a4/c1523f7c9e47b2271dbf8c2a097e7a1f89ef0d66f5840bb59b7e8814157b/coverage-7.13.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e24f9156097ff9dc286f2f913df3a7f63c0e333dcafa3c196f2c18b4175ca09a", size = 246425, upload-time = "2026-02-09T12:56:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/f8/02/aa7ec01d1a5023c4b680ab7257f9bfde9defe8fdddfe40be096ac19e8177/coverage-7.13.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8041b6c5bfdc03257666e9881d33b1abc88daccaf73f7b6340fb7946655cd10f", size = 248229, upload-time = "2026-02-09T12:56:16.31Z" }, + { url = "https://files.pythonhosted.org/packages/35/98/85aba0aed5126d896162087ef3f0e789a225697245256fc6181b95f47207/coverage-7.13.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a09cfa6a5862bc2fc6ca7c3def5b2926194a56b8ab78ffcf617d28911123012", size = 250106, upload-time = "2026-02-09T12:56:18.024Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1db59bd67494bc162e3e4cd5fbc7edba2c7026b22f7c8ef1496d58c2b94c/coverage-7.13.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:296f8b0af861d3970c2a4d8c91d48eb4dd4771bcef9baedec6a9b515d7de3def", size = 252021, upload-time = "2026-02-09T12:56:19.272Z" }, + { url = "https://files.pythonhosted.org/packages/9d/97/72899c59c7066961de6e3daa142d459d47d104956db43e057e034f015c8a/coverage-7.13.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e101609bcbbfb04605ea1027b10dc3735c094d12d40826a60f897b98b1c30256", size = 247114, upload-time = "2026-02-09T12:56:21.051Z" }, + { url = "https://files.pythonhosted.org/packages/39/1f/f1885573b5970235e908da4389176936c8933e86cb316b9620aab1585fa2/coverage-7.13.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aa3feb8db2e87ff5e6d00d7e1480ae241876286691265657b500886c98f38bda", size = 248143, upload-time = "2026-02-09T12:56:22.585Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cf/e80390c5b7480b722fa3e994f8202807799b85bc562aa4f1dde209fbb7be/coverage-7.13.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4fc7fa81bbaf5a02801b65346c8b3e657f1d93763e58c0abdf7c992addd81a92", size = 246152, upload-time = "2026-02-09T12:56:23.748Z" }, + { url = "https://files.pythonhosted.org/packages/44/bf/f89a8350d85572f95412debb0fb9bb4795b1d5b5232bd652923c759e787b/coverage-7.13.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:33901f604424145c6e9c2398684b92e176c0b12df77d52db81c20abd48c3794c", size = 249959, upload-time = "2026-02-09T12:56:25.209Z" }, + { url = "https://files.pythonhosted.org/packages/f7/6e/612a02aece8178c818df273e8d1642190c4875402ca2ba74514394b27aba/coverage-7.13.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:bb28c0f2cf2782508a40cec377935829d5fcc3ad9a3681375af4e84eb34b6b58", size = 246416, upload-time = "2026-02-09T12:56:26.475Z" }, + { url = "https://files.pythonhosted.org/packages/cb/98/b5afc39af67c2fa6786b03c3a7091fc300947387ce8914b096db8a73d67a/coverage-7.13.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d107aff57a83222ddbd8d9ee705ede2af2cc926608b57abed8ef96b50b7e8f9", size = 247025, upload-time = "2026-02-09T12:56:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/51/30/2bba8ef0682d5bd210c38fe497e12a06c9f8d663f7025e9f5c2c31ce847d/coverage-7.13.4-cp310-cp310-win32.whl", hash = "sha256:a6f94a7d00eb18f1b6d403c91a88fd58cfc92d4b16080dfdb774afc8294469bf", size = 221758, upload-time = "2026-02-09T12:56:29.051Z" }, + { url = "https://files.pythonhosted.org/packages/78/13/331f94934cf6c092b8ea59ff868eb587bc8fe0893f02c55bc6c0183a192e/coverage-7.13.4-cp310-cp310-win_amd64.whl", hash = "sha256:2cb0f1e000ebc419632bbe04366a8990b6e32c4e0b51543a6484ffe15eaeda95", size = 222693, upload-time = "2026-02-09T12:56:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" }, + { url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" }, + { url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" }, + { url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" }, + { url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" }, + { url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" }, + { url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" }, + { url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" }, + { url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" }, + { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, + { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, + { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, + { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, + { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, + { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, + { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, + { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, + { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, + { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, + { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, + { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, + { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, + { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, + { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, + { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, + { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, + { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, + { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, + { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, + { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, + { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, + { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, + { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, + { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, + { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, + { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, + { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, + { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version >= '3.10' and python_full_version <= '3.11'" }, +] + +[[package]] +name = "cryptography" +version = "46.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, + { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, +] + +[[package]] +name = "ect-refimpl" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "cryptography" }, + { name = "pyjwt" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest-cov" }, +] + +[package.metadata] +requires-dist = [ + { name = "cryptography", specifier = ">=42.0.0" }, + { name = "pyjwt", specifier = ">=2.8.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "pluggy", marker = "python_full_version < '3.10'" }, + { name = "pygments", marker = "python_full_version < '3.10'" }, + { name = "tomli", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, + { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "pluggy", marker = "python_full_version >= '3.10'" }, + { name = "pygments", marker = "python_full_version >= '3.10'" }, + { name = "tomli", marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version < '3.10'" }, + { name = "coverage", version = "7.13.4", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version >= '3.10'" }, + { name = "pluggy" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] diff --git a/refimpl/testdata/valid_root_ect_payload.json b/refimpl/testdata/valid_root_ect_payload.json deleted file mode 100644 index 667212d..0000000 --- a/refimpl/testdata/valid_root_ect_payload.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "iss": "spiffe://example.com/agent/clinical", - "sub": "spiffe://example.com/agent/clinical", - "aud": "spiffe://example.com/agent/safety", - "iat": 1772064150, - "exp": 1772064750, - "jti": "7f3a8b2c-d1e4-4f56-9a0b-c3d4e5f6a7b8", - "wid": "a0b1c2d3-e4f5-6789-abcd-ef0123456789", - "tid": "550e8400-e29b-41d4-a716-446655440001", - "exec_act": "recommend_treatment", - "par": [], - "pol": "clinical_reasoning_policy_v2", - "pol_decision": "approved" -}