Restructure refimpl into go-lang and python subdirectories
Move Go reference implementation to refimpl/go-lang/ and add new Python reference implementation in refimpl/python/. Update build.sh with renamed draft and simplified tool paths. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
19
build.sh
19
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"
|
||||
|
||||
|
||||
60
refimpl/IMPROVEMENTS.md
Normal file
60
refimpl/IMPROVEMENTS.md
Normal file
@@ -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.
|
||||
@@ -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`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
107
refimpl/go-lang/README.md
Normal file
107
refimpl/go-lang/README.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# WIMSE ECT — Go Reference Implementation
|
||||
|
||||
Go reference implementation of [Execution Context Tokens (ECTs)](../../draft-nennemann-wimse-execution-context-00.txt) for WIMSE. Implements ECT creation (ES256), verification (Section 7), DAG validation (Section 6), and an in-memory audit ledger (Section 9).
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
go-lang/
|
||||
├── go.mod, go.sum
|
||||
├── ect/ # library
|
||||
│ ├── types.go # Payload, Audience, constants
|
||||
│ ├── audience.go # aud marshal/unmarshal
|
||||
│ ├── create.go # Create(), GenerateKey()
|
||||
│ ├── verify.go # Parse(), Verify(), VerifyOptions
|
||||
│ ├── dag.go # ValidateDAG(), ECTStore
|
||||
│ ├── ledger.go # Ledger, MemoryLedger
|
||||
│ ├── config.go # Config, LoadConfigFromEnv()
|
||||
│ ├── jti_cache.go # JTICache for replay protection
|
||||
│ ├── errors.go # Sentinel errors (ErrExpired, ErrReplay, etc.)
|
||||
│ ├── validate.go # ValidateExt, ValidUUID, ValidateHashFormat
|
||||
│ └── *_test.go
|
||||
├── testdata/
|
||||
│ └── valid_root_ect_payload.json
|
||||
└── cmd/demo/ # two-agent workflow demo
|
||||
└── main.go
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Library
|
||||
|
||||
```go
|
||||
import "github.com/nennemann/ect-refimpl/go-lang/ect"
|
||||
|
||||
// Production: load config from environment
|
||||
cfg := ect.LoadConfigFromEnv()
|
||||
|
||||
key, _ := ect.GenerateKey()
|
||||
payload := &ect.Payload{
|
||||
Iss: "spiffe://example.com/agent/a",
|
||||
Aud: ect.Audience{"spiffe://example.com/agent/b"},
|
||||
Iat: time.Now().Unix(),
|
||||
Exp: time.Now().Add(10*time.Minute).Unix(),
|
||||
Jti: "550e8400-e29b-41d4-a716-446655440000",
|
||||
ExecAct: "review_spec",
|
||||
Par: []string{},
|
||||
Pol: "policy_v1",
|
||||
PolDecision: ect.PolDecisionApproved,
|
||||
}
|
||||
compact, err := ect.Create(payload, key, cfg.CreateOptions("agent-a-key"))
|
||||
|
||||
// Verify with optional replay cache
|
||||
store := ect.NewMemoryLedger()
|
||||
var jtiCache ect.JTICache
|
||||
if cfg.JTIReplaySize > 0 {
|
||||
jtiCache = ect.NewJTICache(cfg.JTIReplaySize, cfg.JTIReplayTTL)
|
||||
}
|
||||
opts := cfg.VerifyOptions()
|
||||
opts.VerifierID = "spiffe://example.com/agent/b"
|
||||
opts.ResolveKey = resolver
|
||||
opts.Store = store
|
||||
if jtiCache != nil {
|
||||
opts.JTISeen = jtiCache.Seen
|
||||
}
|
||||
parsed, err := ect.Verify(compact, opts)
|
||||
if err != nil { ... }
|
||||
if jtiCache != nil {
|
||||
jtiCache.Add(parsed.Payload.Jti)
|
||||
}
|
||||
store.Append(compact, parsed.Payload)
|
||||
```
|
||||
|
||||
### Demo
|
||||
|
||||
```bash
|
||||
cd refimpl/go-lang && go run ./cmd/demo
|
||||
```
|
||||
|
||||
### Tests
|
||||
|
||||
```bash
|
||||
cd refimpl/go-lang && go test ./ect/... -cover
|
||||
```
|
||||
|
||||
Unit tests are in `ect/*_test.go`. Coverage target: **~90%** (run `go test ./ect/... -coverprofile=cover.out && go tool cover -func=cover.out`). Remaining uncovered lines are mostly Parse/Verify error paths that require custom JWS or multi-sig tokens.
|
||||
|
||||
## Production configuration (environment)
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `ECT_IAT_MAX_AGE_MINUTES` | 15 | Max age of `iat` (minutes). |
|
||||
| `ECT_IAT_MAX_FUTURE_SEC` | 30 | Max `iat` in future / clock skew (seconds). |
|
||||
| `ECT_DEFAULT_EXPIRY_MIN` | 10 | Default token expiry when `exp` is zero (minutes). |
|
||||
| `ECT_JTI_REPLAY_CACHE_SIZE` | 0 | Max JTIs to remember for replay check; 0 = disabled. |
|
||||
| `ECT_JTI_REPLAY_TTL_MIN` | 60 | TTL for JTI cache entries (minutes). |
|
||||
|
||||
### Replay cache (multi-instance)
|
||||
|
||||
`JTICache` is in-memory only. For multiple verifier instances (e.g. behind a load balancer), use a shared store (Redis, database) so every instance sees the same “seen” JTIs. Implement `JTISeen` as a function that checks (and optionally records) the JTI in that store (e.g. with TTL). Pass it in `VerifyOptions.JTISeen`. See refimpl/README for an overview.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- [go-jose/v4](https://github.com/go-jose/go-jose/v4) for JWS (ES256) and JWK handling.
|
||||
|
||||
## License
|
||||
|
||||
Same as the Internet-Draft (IETF Trust). Code under Revised BSD per BCP 78/79.
|
||||
@@ -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)
|
||||
}
|
||||
84
refimpl/go-lang/ect/config.go
Normal file
84
refimpl/go-lang/ect/config.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package ect
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Env names for production configuration. All optional; zero values use defaults.
|
||||
const (
|
||||
EnvIATMaxAgeMinutes = "ECT_IAT_MAX_AGE_MINUTES" // max age of iat (default 15)
|
||||
EnvIATMaxFutureSec = "ECT_IAT_MAX_FUTURE_SEC" // max iat in future / clock skew (default 30)
|
||||
EnvDefaultExpiryMin = "ECT_DEFAULT_EXPIRY_MIN" // default token expiry when exp is zero (default 10)
|
||||
EnvJTIReplayCacheSize = "ECT_JTI_REPLAY_CACHE_SIZE" // max JTIs to remember for replay check (0 = disabled)
|
||||
EnvJTIReplayTTLMin = "ECT_JTI_REPLAY_TTL_MIN" // TTL for JTI cache entries (default 60)
|
||||
)
|
||||
|
||||
// Config holds production-friendly settings loaded from environment.
|
||||
type Config struct {
|
||||
IATMaxAge time.Duration
|
||||
IATMaxFuture time.Duration
|
||||
DefaultExpiry time.Duration
|
||||
JTIReplaySize int // 0 = no replay cache
|
||||
JTIReplayTTL time.Duration
|
||||
}
|
||||
|
||||
// DefaultConfig returns in-process defaults (no env).
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
IATMaxAge: 15 * time.Minute,
|
||||
IATMaxFuture: 30 * time.Second,
|
||||
DefaultExpiry: 10 * time.Minute,
|
||||
JTIReplayTTL: 60 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// LoadConfigFromEnv returns Config with values from environment where set.
|
||||
func LoadConfigFromEnv() Config {
|
||||
c := DefaultConfig()
|
||||
if v := os.Getenv(EnvIATMaxAgeMinutes); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
c.IATMaxAge = time.Duration(n) * time.Minute
|
||||
}
|
||||
}
|
||||
if v := os.Getenv(EnvIATMaxFutureSec); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||
c.IATMaxFuture = time.Duration(n) * time.Second
|
||||
}
|
||||
}
|
||||
if v := os.Getenv(EnvDefaultExpiryMin); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
c.DefaultExpiry = time.Duration(n) * time.Minute
|
||||
}
|
||||
}
|
||||
if v := os.Getenv(EnvJTIReplayCacheSize); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||
c.JTIReplaySize = n
|
||||
}
|
||||
}
|
||||
if v := os.Getenv(EnvJTIReplayTTLMin); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
c.JTIReplayTTL = time.Duration(n) * time.Minute
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// CreateOptions returns CreateOptions from this config.
|
||||
func (c Config) CreateOptions(keyID string) CreateOptions {
|
||||
return CreateOptions{
|
||||
KeyID: keyID,
|
||||
IATMaxAge: c.IATMaxAge,
|
||||
DefaultExpiry: c.DefaultExpiry,
|
||||
}
|
||||
}
|
||||
|
||||
// VerifyOptions returns VerifyOptions from this config (VerifierID, ResolveKey, Store must be set by caller).
|
||||
func (c Config) VerifyOptions() VerifyOptions {
|
||||
opts := DefaultVerifyOptions()
|
||||
opts.IATMaxAge = c.IATMaxAge
|
||||
opts.IATMaxFuture = c.IATMaxFuture
|
||||
opts.DAG = DefaultDAGConfig()
|
||||
return opts
|
||||
}
|
||||
79
refimpl/go-lang/ect/config_test.go
Normal file
79
refimpl/go-lang/ect/config_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package ect
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDefaultConfig(t *testing.T) {
|
||||
c := DefaultConfig()
|
||||
if c.IATMaxAge != 15*time.Minute {
|
||||
t.Errorf("IATMaxAge: got %v", c.IATMaxAge)
|
||||
}
|
||||
if c.JTIReplaySize != 0 {
|
||||
t.Errorf("JTIReplaySize: got %d", c.JTIReplaySize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigFromEnv(t *testing.T) {
|
||||
os.Setenv(EnvIATMaxAgeMinutes, "20")
|
||||
os.Setenv(EnvJTIReplayCacheSize, "1000")
|
||||
os.Setenv(EnvIATMaxFutureSec, "45")
|
||||
os.Setenv(EnvDefaultExpiryMin, "12")
|
||||
os.Setenv(EnvJTIReplayTTLMin, "90")
|
||||
defer func() {
|
||||
os.Unsetenv(EnvIATMaxAgeMinutes)
|
||||
os.Unsetenv(EnvJTIReplayCacheSize)
|
||||
os.Unsetenv(EnvIATMaxFutureSec)
|
||||
os.Unsetenv(EnvDefaultExpiryMin)
|
||||
os.Unsetenv(EnvJTIReplayTTLMin)
|
||||
}()
|
||||
c := LoadConfigFromEnv()
|
||||
if c.IATMaxAge != 20*time.Minute {
|
||||
t.Errorf("IATMaxAge from env: got %v", c.IATMaxAge)
|
||||
}
|
||||
if c.JTIReplaySize != 1000 {
|
||||
t.Errorf("JTIReplaySize from env: got %d", c.JTIReplaySize)
|
||||
}
|
||||
if c.IATMaxFuture != 45*time.Second {
|
||||
t.Errorf("IATMaxFuture: got %v", c.IATMaxFuture)
|
||||
}
|
||||
if c.DefaultExpiry != 12*time.Minute {
|
||||
t.Errorf("DefaultExpiry: got %v", c.DefaultExpiry)
|
||||
}
|
||||
if c.JTIReplayTTL != 90*time.Minute {
|
||||
t.Errorf("JTIReplayTTL: got %v", c.JTIReplayTTL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigFromEnv_invalidInt(t *testing.T) {
|
||||
os.Setenv(EnvIATMaxAgeMinutes, "bad")
|
||||
defer os.Unsetenv(EnvIATMaxAgeMinutes)
|
||||
c := LoadConfigFromEnv()
|
||||
if c.IATMaxAge != 15*time.Minute {
|
||||
t.Errorf("invalid int should keep default: got %v", c.IATMaxAge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_CreateOptions(t *testing.T) {
|
||||
c := DefaultConfig()
|
||||
opts := c.CreateOptions("my-kid")
|
||||
if opts.KeyID != "my-kid" {
|
||||
t.Errorf("KeyID: got %q", opts.KeyID)
|
||||
}
|
||||
if opts.DefaultExpiry != c.DefaultExpiry {
|
||||
t.Errorf("DefaultExpiry: got %v", opts.DefaultExpiry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_VerifyOptions(t *testing.T) {
|
||||
c := DefaultConfig()
|
||||
opts := c.VerifyOptions()
|
||||
if opts.IATMaxAge != c.IATMaxAge {
|
||||
t.Errorf("IATMaxAge: got %v", opts.IATMaxAge)
|
||||
}
|
||||
if opts.DAG.ClockSkewTolerance != DefaultClockSkewTolerance {
|
||||
t.Errorf("DAG.ClockSkewTolerance: got %d", opts.DAG.ClockSkewTolerance)
|
||||
}
|
||||
}
|
||||
@@ -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 opts.MaxParLength > 0 && len(p.Par) > opts.MaxParLength {
|
||||
return ErrParLength
|
||||
}
|
||||
if p.InpHash != "" {
|
||||
if err := ValidateHashFormat(p.InpHash); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if p.OutHash != "" {
|
||||
if err := ValidateHashFormat(p.OutHash); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := ValidateExt(p.Ext); err != nil {
|
||||
return err
|
||||
}
|
||||
// pol/pol_decision are OPTIONAL; if either is set, both must be present and valid
|
||||
if p.Pol != "" || p.PolDecision != "" {
|
||||
if p.Pol == "" || p.PolDecision == "" {
|
||||
return ErrPolPolDecisionPair
|
||||
}
|
||||
if !ValidPolDecision(p.PolDecision) {
|
||||
return errors.New("ect: pol_decision must be approved, rejected, or pending_human_review")
|
||||
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")
|
||||
}
|
||||
}
|
||||
if p.CompensationReason != "" && !p.CompensationRequired {
|
||||
return errors.New("ect: compensation_reason requires compensation_required true")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
192
refimpl/go-lang/ect/create_test.go
Normal file
192
refimpl/go-lang/ect/create_test.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package ect
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCreateRoundtrip(t *testing.T) {
|
||||
key, err := GenerateKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now()
|
||||
payload := &Payload{
|
||||
Iss: "spiffe://example.com/agent/a",
|
||||
Aud: []string{"spiffe://example.com/agent/b"},
|
||||
Iat: now.Unix(),
|
||||
Exp: now.Add(10 * time.Minute).Unix(),
|
||||
Jti: "e4f5a6b7-c8d9-0123-ef01-234567890abc",
|
||||
ExecAct: "review_spec",
|
||||
Par: []string{},
|
||||
Pol: "spec_review_policy_v2",
|
||||
PolDecision: PolDecisionApproved,
|
||||
}
|
||||
compact, err := Create(payload, key, CreateOptions{KeyID: "agent-a-key-1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if compact == "" {
|
||||
t.Fatal("expected non-empty compact JWS")
|
||||
}
|
||||
|
||||
// Verify with same key
|
||||
resolver := func(kid string) (*ecdsa.PublicKey, error) {
|
||||
if kid != "agent-a-key-1" {
|
||||
return nil, nil
|
||||
}
|
||||
return &key.PublicKey, nil
|
||||
}
|
||||
opts := VerifyOptions{
|
||||
VerifierID: "spiffe://example.com/agent/b",
|
||||
ResolveKey: resolver,
|
||||
Now: now,
|
||||
IATMaxAge: 15 * time.Minute,
|
||||
IATMaxFuture: 30 * time.Second,
|
||||
}
|
||||
parsed, err := Verify(compact, opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parsed.Payload.Jti != payload.Jti || parsed.Payload.ExecAct != payload.ExecAct {
|
||||
t.Errorf("payload mismatch: got jti=%q exec_act=%q", parsed.Payload.Jti, parsed.Payload.ExecAct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultCreateOptions(t *testing.T) {
|
||||
opts := DefaultCreateOptions()
|
||||
if opts.KeyID != "" {
|
||||
t.Errorf("KeyID: got %q", opts.KeyID)
|
||||
}
|
||||
if opts.DefaultExpiry != 10*time.Minute {
|
||||
t.Errorf("DefaultExpiry: got %v", opts.DefaultExpiry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_Errors(t *testing.T) {
|
||||
key, _ := GenerateKey()
|
||||
payload := &Payload{Iss: "i", Aud: []string{"a"}, Jti: "j", ExecAct: "e", Par: []string{}, Pol: "p", PolDecision: PolDecisionApproved, Iat: 1, Exp: 2}
|
||||
if _, err := Create(nil, key, CreateOptions{KeyID: "k"}); err == nil {
|
||||
t.Error("expected error for nil payload")
|
||||
}
|
||||
if _, err := Create(payload, nil, CreateOptions{KeyID: "k"}); err == nil {
|
||||
t.Error("expected error for nil key")
|
||||
}
|
||||
if _, err := Create(payload, key, CreateOptions{KeyID: ""}); err == nil {
|
||||
t.Error("expected error for empty KeyID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_OptionalPol(t *testing.T) {
|
||||
key, _ := GenerateKey()
|
||||
now := time.Now()
|
||||
payload := &Payload{
|
||||
Iss: "iss", Aud: []string{"aud"}, Iat: now.Unix(), Exp: now.Add(time.Hour).Unix(),
|
||||
Jti: "jti-nopol", ExecAct: "act", Par: []string{},
|
||||
}
|
||||
compact, err := Create(payload, key, CreateOptions{KeyID: "kid"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if compact == "" {
|
||||
t.Fatal("expected token without pol")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_ZeroExpiryUsesDefault(t *testing.T) {
|
||||
key, _ := GenerateKey()
|
||||
payload := &Payload{
|
||||
Iss: "i", Aud: []string{"a"}, Iat: 0, Exp: 0,
|
||||
Jti: "jti-z", ExecAct: "e", Par: []string{},
|
||||
}
|
||||
_, err := Create(payload, key, CreateOptions{KeyID: "kid", DefaultExpiry: 5 * time.Minute})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload.Exp <= payload.Iat {
|
||||
t.Error("exp should be after iat")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_ExtCompensationReasonRequiresRequired(t *testing.T) {
|
||||
key, _ := GenerateKey()
|
||||
payload := &Payload{
|
||||
Iss: "i", Aud: []string{"a"}, Iat: 1, Exp: 2,
|
||||
Jti: "j", ExecAct: "e", Par: []string{},
|
||||
Ext: map[string]interface{}{"compensation_reason": "rollback", "compensation_required": false},
|
||||
}
|
||||
_, err := Create(payload, key, CreateOptions{KeyID: "k"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when ext has compensation_reason without compensation_required true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_ValidationErrors(t *testing.T) {
|
||||
key, _ := GenerateKey()
|
||||
tests := []struct {
|
||||
name string
|
||||
p *Payload
|
||||
}{
|
||||
{"missing iss", &Payload{Iss: "", Aud: []string{"a"}, Jti: "j", ExecAct: "e", Par: []string{}, Iat: 1, Exp: 2}},
|
||||
{"missing aud", &Payload{Iss: "i", Aud: nil, Jti: "j", ExecAct: "e", Par: []string{}, Iat: 1, Exp: 2}},
|
||||
{"missing jti", &Payload{Iss: "i", Aud: []string{"a"}, Jti: "", ExecAct: "e", Par: []string{}, Iat: 1, Exp: 2}},
|
||||
{"missing exec_act", &Payload{Iss: "i", Aud: []string{"a"}, Jti: "j", ExecAct: "", Par: []string{}, Iat: 1, Exp: 2}},
|
||||
{"pol without pol_decision", &Payload{Iss: "i", Aud: []string{"a"}, Jti: "j", ExecAct: "e", Par: []string{}, Pol: "p", PolDecision: "", Iat: 1, Exp: 2}},
|
||||
{"invalid pol_decision", &Payload{Iss: "i", Aud: []string{"a"}, Jti: "j", ExecAct: "e", Par: []string{}, Pol: "p", PolDecision: "bad", Iat: 1, Exp: 2}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if _, err := Create(tt.p, key, CreateOptions{KeyID: "k"}); err == nil {
|
||||
t.Error("expected validation error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateWithTestVector(t *testing.T) {
|
||||
// testdata at module root (go-lang/testdata/); test cwd is package dir (ect/)
|
||||
data, err := os.ReadFile("../testdata/valid_root_ect_payload.json")
|
||||
if err != nil {
|
||||
data, err = os.ReadFile("testdata/valid_root_ect_payload.json")
|
||||
}
|
||||
if err != nil {
|
||||
t.Skipf("test vector not found: %v", err)
|
||||
return
|
||||
}
|
||||
var p Payload
|
||||
if err := json.Unmarshal(data, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, err := GenerateKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Override timestamps for verification
|
||||
now := time.Now()
|
||||
p.Iat = now.Unix()
|
||||
p.Exp = now.Add(10 * time.Minute).Unix()
|
||||
|
||||
compact, err := Create(&p, key, CreateOptions{KeyID: "test-kid"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolver := func(kid string) (*ecdsa.PublicKey, error) {
|
||||
if kid != "test-kid" {
|
||||
return nil, nil
|
||||
}
|
||||
return &key.PublicKey, nil
|
||||
}
|
||||
_, err = Verify(compact, VerifyOptions{
|
||||
VerifierID: p.Aud[0],
|
||||
ResolveKey: resolver,
|
||||
Now: now,
|
||||
IATMaxAge: 15 * time.Minute,
|
||||
IATMaxFuture: 30 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
140
refimpl/go-lang/ect/dag_test.go
Normal file
140
refimpl/go-lang/ect/dag_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package ect
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestValidateDAG_Root(t *testing.T) {
|
||||
store := NewMemoryLedger()
|
||||
payload := &Payload{
|
||||
Jti: "jti-001",
|
||||
Wid: "wf-1",
|
||||
Par: []string{},
|
||||
PolDecision: PolDecisionApproved,
|
||||
}
|
||||
err := ValidateDAG(payload, store, DefaultDAGConfig())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDAG_DuplicateJti(t *testing.T) {
|
||||
store := NewMemoryLedger()
|
||||
_, _ = store.Append("dummy-jws", &Payload{Jti: "jti-001", Wid: "wf-1", Par: []string{}, PolDecision: PolDecisionApproved})
|
||||
payload := &Payload{Jti: "jti-001", Wid: "wf-1", Par: []string{}, PolDecision: PolDecisionApproved}
|
||||
err := ValidateDAG(payload, store, DefaultDAGConfig())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate jti")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDAG_ParentExists(t *testing.T) {
|
||||
store := NewMemoryLedger()
|
||||
_, _ = store.Append("jws1", &Payload{Jti: "jti-001", Wid: "wf-1", Par: []string{}, PolDecision: PolDecisionApproved, Iat: time.Now().Unix() - 60})
|
||||
payload := &Payload{
|
||||
Jti: "jti-002",
|
||||
Wid: "wf-1",
|
||||
Par: []string{"jti-001"},
|
||||
PolDecision: PolDecisionApproved,
|
||||
Iat: time.Now().Unix(),
|
||||
}
|
||||
err := ValidateDAG(payload, store, DefaultDAGConfig())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDAG_ParentNotFound(t *testing.T) {
|
||||
store := NewMemoryLedger()
|
||||
payload := &Payload{
|
||||
Jti: "jti-002",
|
||||
Par: []string{"jti-missing"},
|
||||
PolDecision: PolDecisionApproved,
|
||||
Iat: time.Now().Unix(),
|
||||
}
|
||||
err := ValidateDAG(payload, store, DefaultDAGConfig())
|
||||
if err == nil {
|
||||
t.Fatal("expected error when parent not found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDAG_DepthLimit(t *testing.T) {
|
||||
store := NewMemoryLedger()
|
||||
now := time.Now().Unix()
|
||||
// Chain: jti-1 -> jti-2 -> jti-3 -> ...; validate with maxAncestorLimit=2 so we exceed it
|
||||
_, _ = store.Append("jws1", &Payload{Jti: "jti-1", Wid: "wf", Par: []string{}, PolDecision: PolDecisionApproved, Iat: now - 100})
|
||||
_, _ = store.Append("jws2", &Payload{Jti: "jti-2", Wid: "wf", Par: []string{"jti-1"}, PolDecision: PolDecisionApproved, Iat: now - 50})
|
||||
cfg := DAGConfig{ClockSkewTolerance: DefaultClockSkewTolerance, MaxAncestorLimit: 2}
|
||||
payload := &Payload{Jti: "jti-3", Wid: "wf", Par: []string{"jti-2"}, PolDecision: PolDecisionApproved, Iat: now}
|
||||
err := ValidateDAG(payload, store, cfg)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when ancestor limit exceeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDAG_StoreNil(t *testing.T) {
|
||||
payload := &Payload{Jti: "j1", Par: []string{}, PolDecision: PolDecisionApproved, Iat: time.Now().Unix()}
|
||||
err := ValidateDAG(payload, nil, DefaultDAGConfig())
|
||||
if err == nil {
|
||||
t.Fatal("expected error when store is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDAG_TemporalOrdering(t *testing.T) {
|
||||
store := NewMemoryLedger()
|
||||
now := time.Now().Unix()
|
||||
_, _ = store.Append("jws1", &Payload{Jti: "jti-1", Wid: "wf", Par: []string{}, PolDecision: PolDecisionApproved, Iat: now})
|
||||
// child has iat before parent + skew: parent.iat (now) >= child.iat (now+100) + 30 => invalid
|
||||
payload := &Payload{Jti: "jti-2", Wid: "wf", Par: []string{"jti-1"}, PolDecision: PolDecisionApproved, Iat: now + 100}
|
||||
err := ValidateDAG(payload, store, DAGConfig{ClockSkewTolerance: 30, MaxAncestorLimit: 10000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// parent.iat >= child.iat + skew: parent at now+50, child at now+10, skew 30 => 50 >= 40 => invalid
|
||||
_, _ = store.Append("jws2", &Payload{Jti: "jti-1b", Wid: "wf", Par: []string{}, PolDecision: PolDecisionApproved, Iat: now + 50})
|
||||
payload2 := &Payload{Jti: "jti-2b", Wid: "wf", Par: []string{"jti-1b"}, PolDecision: PolDecisionApproved, Iat: now + 10}
|
||||
err = ValidateDAG(payload2, store, DAGConfig{ClockSkewTolerance: 30, MaxAncestorLimit: 10000})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when parent not earlier than child")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDAG_DirectCycle(t *testing.T) {
|
||||
// par contains own jti (direct self-reference) -> parent not found
|
||||
store := NewMemoryLedger()
|
||||
now := time.Now().Unix()
|
||||
payload := &Payload{Jti: "jti-self", Wid: "wf", Par: []string{"jti-self"}, PolDecision: PolDecisionApproved, Iat: now}
|
||||
err := ValidateDAG(payload, store, DefaultDAGConfig())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for direct cycle (par contains self)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDAG_hasCycle_visitedContinue(t *testing.T) {
|
||||
// par has duplicate parent ID so we hit "if _, ok := visited[parentID]; ok { continue }"
|
||||
store := NewMemoryLedger()
|
||||
now := time.Now().Unix()
|
||||
_, _ = store.Append("jws1", &Payload{Jti: "jti-a", Wid: "wf", Par: []string{}, PolDecision: PolDecisionApproved, Iat: now - 10})
|
||||
payload := &Payload{Jti: "jti-b", Wid: "wf", Par: []string{"jti-a", "jti-a"}, PolDecision: PolDecisionApproved, Iat: now}
|
||||
err := ValidateDAG(payload, store, DefaultDAGConfig())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDAG_ParentPolicyRejected_RequiresCompensation(t *testing.T) {
|
||||
store := NewMemoryLedger()
|
||||
now := time.Now().Unix()
|
||||
_, _ = store.Append("jws1", &Payload{Jti: "jti-rej", Wid: "wf", Par: []string{}, Pol: "p", PolDecision: PolDecisionRejected, Iat: now - 60})
|
||||
payload := &Payload{Jti: "jti-child", Wid: "wf", Par: []string{"jti-rej"}, PolDecision: PolDecisionApproved, Iat: now}
|
||||
err := ValidateDAG(payload, store, DefaultDAGConfig())
|
||||
if err == nil {
|
||||
t.Fatal("expected error when parent rejected and no compensation")
|
||||
}
|
||||
payload.Ext = map[string]interface{}{"compensation_required": true}
|
||||
err = ValidateDAG(payload, store, DefaultDAGConfig())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
29
refimpl/go-lang/ect/errors.go
Normal file
29
refimpl/go-lang/ect/errors.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package ect
|
||||
|
||||
import "errors"
|
||||
|
||||
// Sentinel errors for programmatic handling and logging.
|
||||
var (
|
||||
ErrPayloadRequired = errors.New("ect: payload and privateKey required")
|
||||
ErrKeyIDRequired = errors.New("ect: KeyID required")
|
||||
ErrInvalidTyp = errors.New("ect: invalid typ parameter")
|
||||
ErrProhibitedAlg = errors.New("ect: prohibited algorithm")
|
||||
ErrMissingKid = errors.New("ect: missing kid")
|
||||
ErrUnknownKey = errors.New("ect: unknown key identifier")
|
||||
ErrWITSubjectMismatch = errors.New("ect: issuer does not match WIT subject")
|
||||
ErrAudienceMismatch = errors.New("ect: audience does not include verifier")
|
||||
ErrExpired = errors.New("ect: token expired")
|
||||
ErrIATTooOld = errors.New("ect: iat too far in the past")
|
||||
ErrIATInFuture = errors.New("ect: iat in the future")
|
||||
ErrMissingClaims = errors.New("ect: missing required claims (jti, exec_act, par)")
|
||||
ErrPolPolDecisionPair = errors.New("ect: pol and pol_decision must both be present when either is set")
|
||||
ErrInvalidPolDecision = errors.New("ect: invalid pol_decision value")
|
||||
ErrReplay = errors.New("ect: jti already seen (replay)")
|
||||
ErrResolveKeyRequired = errors.New("ect: ResolveKey required")
|
||||
ErrExtSize = errors.New("ect: ext exceeds max size (4096 bytes)")
|
||||
ErrExtDepth = errors.New("ect: ext exceeds max nesting depth (5)")
|
||||
ErrInvalidJTI = errors.New("ect: jti must be UUID format")
|
||||
ErrInvalidWID = errors.New("ect: wid must be UUID format when set")
|
||||
ErrParLength = errors.New("ect: par exceeds max length")
|
||||
ErrHashFormat = errors.New("ect: inp_hash/out_hash must be algorithm:base64url (e.g. sha-256:...)")
|
||||
)
|
||||
69
refimpl/go-lang/ect/jti_cache.go
Normal file
69
refimpl/go-lang/ect/jti_cache.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package ect
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// JTICache provides replay protection by remembering seen JTIs. Safe for concurrent use.
|
||||
// Use as VerifyOptions.JTISeen: cache.Seen(jti) and call cache.Add(jti) after successful verify.
|
||||
type JTICache interface {
|
||||
// Seen returns true if jti was already added (replay).
|
||||
Seen(jti string) bool
|
||||
// Add records jti. Call after successful verification before accepting the token.
|
||||
Add(jti string)
|
||||
}
|
||||
|
||||
type jtiEntry struct {
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// NewJTICache returns an in-memory JTI cache with optional max size and TTL.
|
||||
// maxSize 0 means unbounded; entries are evicted after ttl.
|
||||
func NewJTICache(maxSize int, ttl time.Duration) JTICache {
|
||||
return &jtiCache{
|
||||
byJti: make(map[string]jtiEntry),
|
||||
maxSize: maxSize,
|
||||
ttl: ttl,
|
||||
}
|
||||
}
|
||||
|
||||
type jtiCache struct {
|
||||
mu sync.RWMutex
|
||||
byJti map[string]jtiEntry
|
||||
maxSize int
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func (c *jtiCache) Seen(jti string) bool {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
e, ok := c.byJti[jti]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if time.Now().After(e.expiresAt) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *jtiCache) Add(jti string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
now := time.Now()
|
||||
// Evict expired
|
||||
for k, v := range c.byJti {
|
||||
if now.After(v.expiresAt) {
|
||||
delete(c.byJti, k)
|
||||
}
|
||||
}
|
||||
if c.maxSize > 0 && len(c.byJti) >= c.maxSize && c.byJti[jti].expiresAt.IsZero() {
|
||||
// Evict one oldest (simple: remove first we see)
|
||||
for k := range c.byJti {
|
||||
delete(c.byJti, k)
|
||||
break
|
||||
}
|
||||
}
|
||||
c.byJti[jti] = jtiEntry{expiresAt: now.Add(c.ttl)}
|
||||
}
|
||||
56
refimpl/go-lang/ect/jti_cache_test.go
Normal file
56
refimpl/go-lang/ect/jti_cache_test.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package ect
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestJTICache_SeenAndAdd(t *testing.T) {
|
||||
cache := NewJTICache(10, 1*time.Second)
|
||||
if cache.Seen("jti-1") {
|
||||
t.Error("unseen jti should not be seen")
|
||||
}
|
||||
cache.Add("jti-1")
|
||||
if !cache.Seen("jti-1") {
|
||||
t.Error("added jti should be seen")
|
||||
}
|
||||
if cache.Seen("jti-2") {
|
||||
t.Error("other jti should not be seen")
|
||||
}
|
||||
cache.Add("jti-2")
|
||||
if !cache.Seen("jti-2") {
|
||||
t.Error("second jti should be seen")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJTICache_Expiry(t *testing.T) {
|
||||
cache := NewJTICache(10, 50*time.Millisecond)
|
||||
cache.Add("jti-1")
|
||||
if !cache.Seen("jti-1") {
|
||||
t.Fatal("added jti should be seen")
|
||||
}
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
if cache.Seen("jti-1") {
|
||||
t.Error("expired jti should not be seen")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJTICache_MaxSizeEviction(t *testing.T) {
|
||||
cache := NewJTICache(2, 5*time.Second)
|
||||
cache.Add("jti-1")
|
||||
cache.Add("jti-2")
|
||||
cache.Add("jti-3") // evicts one
|
||||
// At least one of jti-1, jti-2 may be evicted; jti-3 must be present
|
||||
if !cache.Seen("jti-3") {
|
||||
t.Error("newest jti should be present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJTICache_AddWhenAlreadyPresent(t *testing.T) {
|
||||
cache := NewJTICache(2, 5*time.Second)
|
||||
cache.Add("jti-1")
|
||||
cache.Add("jti-1") // refresh same jti; should not evict
|
||||
if !cache.Seen("jti-1") {
|
||||
t.Error("jti-1 should still be present")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
58
refimpl/go-lang/ect/ledger_test.go
Normal file
58
refimpl/go-lang/ect/ledger_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package ect
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMemoryLedger_AppendAndGet(t *testing.T) {
|
||||
m := NewMemoryLedger()
|
||||
p := &Payload{Jti: "jti-1", Iss: "iss", ExecAct: "act", Par: []string{}, Iat: time.Now().Unix(), Exp: time.Now().Add(time.Hour).Unix()}
|
||||
seq, err := m.Append("jws1", p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if seq != 1 {
|
||||
t.Errorf("seq: got %d", seq)
|
||||
}
|
||||
got := m.GetByTid("jti-1")
|
||||
if got == nil || got.Jti != "jti-1" {
|
||||
t.Errorf("GetByTid: got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryLedger_ErrTaskIDExists(t *testing.T) {
|
||||
m := NewMemoryLedger()
|
||||
p := &Payload{Jti: "jti-dup", Iss: "i", ExecAct: "e", Par: []string{}, Iat: 1, Exp: 2}
|
||||
_, _ = m.Append("jws1", p)
|
||||
_, err := m.Append("jws2", p)
|
||||
if err != ErrTaskIDExists {
|
||||
t.Errorf("expected ErrTaskIDExists, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryLedger_ContainsWid(t *testing.T) {
|
||||
m := NewMemoryLedger()
|
||||
p := &Payload{Jti: "j1", Wid: "wf1", Iss: "i", ExecAct: "e", Par: []string{}, Iat: 1, Exp: 2}
|
||||
_, _ = m.Append("jws", p)
|
||||
if !m.Contains("j1", "") {
|
||||
t.Error("Contains(j1, \"\") should be true")
|
||||
}
|
||||
if !m.Contains("j1", "wf1") {
|
||||
t.Error("Contains(j1, wf1) should be true")
|
||||
}
|
||||
if m.Contains("j1", "wf2") {
|
||||
t.Error("Contains(j1, wf2) should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryLedger_AppendNilPayload(t *testing.T) {
|
||||
m := NewMemoryLedger()
|
||||
seq, err := m.Append("jws", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if seq != 0 {
|
||||
t.Errorf("Append(nil) should return 0, got %d", seq)
|
||||
}
|
||||
}
|
||||
@@ -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 != ""
|
||||
}
|
||||
115
refimpl/go-lang/ect/types_test.go
Normal file
115
refimpl/go-lang/ect/types_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package ect
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAudience_MarshalJSON_single(t *testing.T) {
|
||||
a := Audience{"single"}
|
||||
b, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(b) != `"single"` {
|
||||
t.Errorf("single aud: got %s", string(b))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudience_MarshalJSON_multi(t *testing.T) {
|
||||
a := Audience{"a", "b"}
|
||||
b, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(b) != `["a","b"]` {
|
||||
t.Errorf("multi aud: got %s", string(b))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudience_UnmarshalJSON_array(t *testing.T) {
|
||||
var a Audience
|
||||
err := json.Unmarshal([]byte(`["x","y"]`), &a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(a) != 2 || a[0] != "x" || a[1] != "y" {
|
||||
t.Errorf("unmarshal array: got %v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudience_UnmarshalJSON_string(t *testing.T) {
|
||||
var a Audience
|
||||
err := json.Unmarshal([]byte(`"single"`), &a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(a) != 1 || a[0] != "single" {
|
||||
t.Errorf("unmarshal string: got %v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudience_UnmarshalJSON_invalid(t *testing.T) {
|
||||
var a Audience
|
||||
// aud must be string or array; object is invalid
|
||||
err := json.Unmarshal([]byte(`{}`), &a)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid aud type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidPolDecision(t *testing.T) {
|
||||
if !ValidPolDecision(PolDecisionApproved) {
|
||||
t.Error("approved should be valid")
|
||||
}
|
||||
if !ValidPolDecision(PolDecisionRejected) {
|
||||
t.Error("rejected should be valid")
|
||||
}
|
||||
if ValidPolDecision("invalid") {
|
||||
t.Error("invalid should not be valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPayload_ContainsAudience(t *testing.T) {
|
||||
p := &Payload{Aud: []string{"a", "b"}}
|
||||
if !p.ContainsAudience("a") {
|
||||
t.Error("should contain a")
|
||||
}
|
||||
if p.ContainsAudience("c") {
|
||||
t.Error("should not contain c")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPayload_IATTime_ExpTime(t *testing.T) {
|
||||
ts := int64(1000000)
|
||||
p := &Payload{Iat: ts, Exp: ts + 100}
|
||||
if !p.IATTime().Equal(time.Unix(ts, 0)) {
|
||||
t.Error("IATTime mismatch")
|
||||
}
|
||||
if !p.ExpTime().Equal(time.Unix(ts+100, 0)) {
|
||||
t.Error("ExpTime mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPayload_CompensationRequired(t *testing.T) {
|
||||
p := &Payload{}
|
||||
if p.CompensationRequired() {
|
||||
t.Error("nil Ext should not be compensation")
|
||||
}
|
||||
p.Ext = map[string]interface{}{"compensation_required": true}
|
||||
if !p.CompensationRequired() {
|
||||
t.Error("ext compensation_required true should return true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPayload_HasPolicyClaims(t *testing.T) {
|
||||
p := &Payload{Pol: "p", PolDecision: PolDecisionApproved}
|
||||
if !p.HasPolicyClaims() {
|
||||
t.Error("both pol and pol_decision set should have policy claims")
|
||||
}
|
||||
p.Pol = ""
|
||||
if p.HasPolicyClaims() {
|
||||
t.Error("missing pol should not have policy claims")
|
||||
}
|
||||
}
|
||||
83
refimpl/go-lang/ect/validate.go
Normal file
83
refimpl/go-lang/ect/validate.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package ect
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ExtMaxSize is the recommended max serialized size of ext (Section 4.2.7).
|
||||
const ExtMaxSize = 4096
|
||||
|
||||
// ExtMaxDepth is the recommended max JSON nesting depth in ext.
|
||||
const ExtMaxDepth = 5
|
||||
|
||||
// DefaultMaxParLength is the recommended max number of parent references.
|
||||
const DefaultMaxParLength = 100
|
||||
|
||||
// uuidRegex matches RFC 9562 UUID: 8-4-4-4-12 hex.
|
||||
var uuidRegex = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
|
||||
|
||||
// allowedHashAlgs are the spec-recommended hash algorithm prefixes for inp_hash/out_hash.
|
||||
var allowedHashAlgs = map[string]bool{"sha-256": true, "sha-384": true, "sha-512": true}
|
||||
|
||||
// ValidateExt returns an error if ext exceeds ExtMaxSize or ExtMaxDepth.
|
||||
func ValidateExt(ext map[string]interface{}) error {
|
||||
if ext == nil || len(ext) == 0 {
|
||||
return nil
|
||||
}
|
||||
b, err := json.Marshal(ext)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(b) > ExtMaxSize {
|
||||
return ErrExtSize
|
||||
}
|
||||
if depth(b) > ExtMaxDepth {
|
||||
return ErrExtDepth
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func depth(b []byte) int {
|
||||
var max, level int
|
||||
for _, c := range b {
|
||||
switch c {
|
||||
case '{', '[':
|
||||
level++
|
||||
if level > max {
|
||||
max = level
|
||||
}
|
||||
case '}', ']':
|
||||
level--
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
// ValidUUID returns true if s is a UUID string (RFC 9562).
|
||||
func ValidUUID(s string) bool {
|
||||
return uuidRegex.MatchString(s)
|
||||
}
|
||||
|
||||
// ValidateHashFormat returns nil if s is empty or matches "algorithm:base64url" (sha-256, sha-384, sha-512).
|
||||
func ValidateHashFormat(s string) error {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
idx := strings.Index(s, ":")
|
||||
if idx <= 0 {
|
||||
return ErrHashFormat
|
||||
}
|
||||
alg := strings.ToLower(s[:idx])
|
||||
if !allowedHashAlgs[alg] {
|
||||
return ErrHashFormat
|
||||
}
|
||||
encoded := s[idx+1:]
|
||||
if encoded == "" {
|
||||
return ErrHashFormat
|
||||
}
|
||||
_, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||
return err
|
||||
}
|
||||
@@ -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
|
||||
// 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, errors.New("ect: invalid pol_decision value")
|
||||
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
|
||||
243
refimpl/go-lang/ect/verify_test.go
Normal file
243
refimpl/go-lang/ect/verify_test.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package ect
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
key, _ := GenerateKey()
|
||||
now := time.Now()
|
||||
payload := &Payload{
|
||||
Iss: "iss",
|
||||
Aud: []string{"aud"},
|
||||
Iat: now.Unix(),
|
||||
Exp: now.Add(time.Hour).Unix(),
|
||||
Jti: "jti-parse",
|
||||
ExecAct: "act",
|
||||
Par: []string{},
|
||||
Pol: "pol",
|
||||
PolDecision: PolDecisionApproved,
|
||||
}
|
||||
compact, err := Create(payload, key, CreateOptions{KeyID: "kid"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err := Parse(compact)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parsed.Payload.Jti != "jti-parse" {
|
||||
t.Errorf("jti: got %q", parsed.Payload.Jti)
|
||||
}
|
||||
if parsed.Raw != compact {
|
||||
t.Error("Raw should match compact")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_InvalidTyp(t *testing.T) {
|
||||
// Create token then tamper header to test typ check would need different alg or manual JWS;
|
||||
// instead test Verify with bad token
|
||||
_, err := Verify("not-a-jws", VerifyOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JWS")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_Expired(t *testing.T) {
|
||||
key, _ := GenerateKey()
|
||||
now := time.Now()
|
||||
payload := &Payload{
|
||||
Iss: "iss",
|
||||
Aud: []string{"verifier"},
|
||||
Iat: now.Add(-1 * time.Hour).Unix(),
|
||||
Exp: now.Add(-1 * time.Minute).Unix(),
|
||||
Jti: "jti-exp",
|
||||
ExecAct: "act",
|
||||
Par: []string{},
|
||||
Pol: "pol",
|
||||
PolDecision: PolDecisionApproved,
|
||||
}
|
||||
compact, _ := Create(payload, key, CreateOptions{KeyID: "kid"})
|
||||
resolver := func(kid string) (*ecdsa.PublicKey, error) {
|
||||
if kid == "kid" {
|
||||
return &key.PublicKey, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
_, err := Verify(compact, VerifyOptions{
|
||||
VerifierID: "verifier",
|
||||
ResolveKey: resolver,
|
||||
Now: now,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for expired token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_Replay(t *testing.T) {
|
||||
key, _ := GenerateKey()
|
||||
now := time.Now()
|
||||
payload := &Payload{
|
||||
Iss: "iss",
|
||||
Aud: []string{"v"},
|
||||
Iat: now.Unix(),
|
||||
Exp: now.Add(time.Hour).Unix(),
|
||||
Jti: "jti-replay",
|
||||
ExecAct: "act",
|
||||
Par: []string{},
|
||||
Pol: "p",
|
||||
PolDecision: PolDecisionApproved,
|
||||
}
|
||||
compact, _ := Create(payload, key, CreateOptions{KeyID: "kid"})
|
||||
resolver := func(kid string) (*ecdsa.PublicKey, error) {
|
||||
if kid == "kid" {
|
||||
return &key.PublicKey, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
opts := VerifyOptions{
|
||||
VerifierID: "v",
|
||||
ResolveKey: resolver,
|
||||
Now: now,
|
||||
JTISeen: func(jti string) bool { return jti == "jti-replay" },
|
||||
}
|
||||
_, err := Verify(compact, opts)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for replay")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultVerifyOptions(t *testing.T) {
|
||||
opts := DefaultVerifyOptions()
|
||||
if opts.IATMaxAge != 15*time.Minute {
|
||||
t.Errorf("IATMaxAge: got %v", opts.IATMaxAge)
|
||||
}
|
||||
if opts.DAG.ClockSkewTolerance != DefaultClockSkewTolerance {
|
||||
t.Errorf("DAG: got %d", opts.DAG.ClockSkewTolerance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_WITSubjectMismatch(t *testing.T) {
|
||||
key, _ := GenerateKey()
|
||||
now := time.Now()
|
||||
payload := &Payload{
|
||||
Iss: "iss", Aud: []string{"v"}, Iat: now.Unix(), Exp: now.Add(time.Hour).Unix(),
|
||||
Jti: "jti-wit", ExecAct: "act", Par: []string{}, Pol: "p", PolDecision: PolDecisionApproved,
|
||||
}
|
||||
compact, _ := Create(payload, key, CreateOptions{KeyID: "kid"})
|
||||
resolver := func(kid string) (*ecdsa.PublicKey, error) {
|
||||
if kid == "kid" {
|
||||
return &key.PublicKey, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
_, err := Verify(compact, VerifyOptions{
|
||||
VerifierID: "v", ResolveKey: resolver, Now: now, WITSubject: "other-iss",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for WIT subject mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_IATTooFarPast(t *testing.T) {
|
||||
key, _ := GenerateKey()
|
||||
now := time.Now()
|
||||
payload := &Payload{
|
||||
Iss: "iss", Aud: []string{"v"}, Iat: now.Add(-1 * time.Hour).Unix(), Exp: now.Add(time.Hour).Unix(),
|
||||
Jti: "jti-iat", ExecAct: "act", Par: []string{}, Pol: "p", PolDecision: PolDecisionApproved,
|
||||
}
|
||||
compact, _ := Create(payload, key, CreateOptions{KeyID: "kid"})
|
||||
resolver := func(kid string) (*ecdsa.PublicKey, error) {
|
||||
if kid == "kid" {
|
||||
return &key.PublicKey, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
_, err := Verify(compact, VerifyOptions{
|
||||
VerifierID: "v", ResolveKey: resolver, Now: now, IATMaxAge: 30 * time.Minute,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for iat too far in past")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_IATInFuture(t *testing.T) {
|
||||
key, _ := GenerateKey()
|
||||
now := time.Now()
|
||||
payload := &Payload{
|
||||
Iss: "iss", Aud: []string{"v"}, Iat: now.Add(60 * time.Second).Unix(), Exp: now.Add(2 * time.Hour).Unix(),
|
||||
Jti: "jti-fut", ExecAct: "act", Par: []string{}, Pol: "p", PolDecision: PolDecisionApproved,
|
||||
}
|
||||
compact, _ := Create(payload, key, CreateOptions{KeyID: "kid"})
|
||||
resolver := func(kid string) (*ecdsa.PublicKey, error) {
|
||||
if kid == "kid" {
|
||||
return &key.PublicKey, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
_, err := Verify(compact, VerifyOptions{
|
||||
VerifierID: "v", ResolveKey: resolver, Now: now, IATMaxFuture: 30 * time.Second,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for iat in future")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_ResolveKeyError(t *testing.T) {
|
||||
key, _ := GenerateKey()
|
||||
now := time.Now()
|
||||
payload := &Payload{
|
||||
Iss: "iss", Aud: []string{"v"}, Iat: now.Unix(), Exp: now.Add(time.Hour).Unix(),
|
||||
Jti: "jti-err", ExecAct: "act", Par: []string{}, Pol: "p", PolDecision: PolDecisionApproved,
|
||||
}
|
||||
compact, _ := Create(payload, key, CreateOptions{KeyID: "kid"})
|
||||
resolver := func(kid string) (*ecdsa.PublicKey, error) {
|
||||
return nil, errors.New("key lookup failed")
|
||||
}
|
||||
_, err := Verify(compact, VerifyOptions{
|
||||
VerifierID: "v", ResolveKey: resolver, Now: now,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error from ResolveKey")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_WithDAG(t *testing.T) {
|
||||
key, _ := GenerateKey()
|
||||
ledger := NewMemoryLedger()
|
||||
now := time.Now()
|
||||
root := &Payload{
|
||||
Iss: "iss", Aud: []string{"v"}, Iat: now.Unix(), Exp: now.Add(time.Hour).Unix(),
|
||||
Jti: "jti-root", ExecAct: "act", Par: []string{}, Pol: "p", PolDecision: PolDecisionApproved,
|
||||
}
|
||||
compactRoot, _ := Create(root, key, CreateOptions{KeyID: "kid"})
|
||||
resolver := func(kid string) (*ecdsa.PublicKey, error) {
|
||||
if kid == "kid" {
|
||||
return &key.PublicKey, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
opts := VerifyOptions{
|
||||
VerifierID: "v", ResolveKey: resolver, Store: ledger, Now: now,
|
||||
}
|
||||
parsed, err := Verify(compactRoot, opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = ledger.Append(compactRoot, parsed.Payload)
|
||||
child := &Payload{
|
||||
Iss: "iss", Aud: []string{"v"}, Iat: now.Unix() + 1, Exp: now.Add(time.Hour).Unix(),
|
||||
Jti: "jti-child", ExecAct: "act2", Par: []string{"jti-root"}, Pol: "p", PolDecision: PolDecisionApproved,
|
||||
}
|
||||
compactChild, _ := Create(child, key, CreateOptions{KeyID: "kid"})
|
||||
parsed2, err := Verify(compactChild, opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parsed2.Payload.Jti != "jti-child" {
|
||||
t.Errorf("got %q", parsed2.Payload.Jti)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
module github.com/nennemann/ect-refimpl
|
||||
module github.com/nennemann/ect-refimpl/go-lang
|
||||
|
||||
go 1.22
|
||||
|
||||
1
refimpl/go-lang/testdata/valid_root_ect_payload.json
vendored
Normal file
1
refimpl/go-lang/testdata/valid_root_ect_payload.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"iss":"spiffe://example.com/agent/clinical","sub":"spiffe://example.com/agent/clinical","aud":"spiffe://example.com/agent/safety","iat":1772064150,"exp":1772064750,"jti":"7f3a8b2c-d1e4-4f56-9a0b-c3d4e5f6a7b8","wid":"a0b1c2d3-e4f5-6789-abcd-ef0123456789","exec_act":"recommend_treatment","par":[],"pol":"clinical_reasoning_policy_v2","pol_decision":"approved"}
|
||||
100
refimpl/python/README.md
Normal file
100
refimpl/python/README.md
Normal file
@@ -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.
|
||||
99
refimpl/python/demo.py
Normal file
99
refimpl/python/demo.py
Normal file
@@ -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()
|
||||
61
refimpl/python/ect/__init__.py
Normal file
61
refimpl/python/ect/__init__.py
Normal file
@@ -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",
|
||||
]
|
||||
61
refimpl/python/ect/config.py
Normal file
61
refimpl/python/ect/config.py
Normal file
@@ -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
|
||||
115
refimpl/python/ect/create.py
Normal file
115
refimpl/python/ect/create.py
Normal file
@@ -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())
|
||||
97
refimpl/python/ect/dag.py
Normal file
97
refimpl/python/ect/dag.py
Normal file
@@ -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"
|
||||
)
|
||||
52
refimpl/python/ect/jti_cache.py
Normal file
52
refimpl/python/ect/jti_cache.py
Normal file
@@ -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)
|
||||
97
refimpl/python/ect/ledger.py
Normal file
97
refimpl/python/ect/ledger.py
Normal file
@@ -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
|
||||
128
refimpl/python/ect/types.py
Normal file
128
refimpl/python/ect/types.py
Normal file
@@ -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)
|
||||
65
refimpl/python/ect/validate.py
Normal file
65
refimpl/python/ect/validate.py
Normal file
@@ -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
|
||||
160
refimpl/python/ect/verify.py
Normal file
160
refimpl/python/ect/verify.py
Normal file
@@ -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)
|
||||
25
refimpl/python/pyproject.toml
Normal file
25
refimpl/python/pyproject.toml
Normal file
@@ -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*"]
|
||||
1
refimpl/python/testdata/valid_root_ect_payload.json
vendored
Normal file
1
refimpl/python/testdata/valid_root_ect_payload.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"iss":"spiffe://example.com/agent/clinical","sub":"spiffe://example.com/agent/clinical","aud":"spiffe://example.com/agent/safety","iat":1772064150,"exp":1772064750,"jti":"7f3a8b2c-d1e4-4f56-9a0b-c3d4e5f6a7b8","wid":"a0b1c2d3-e4f5-6789-abcd-ef0123456789","exec_act":"recommend_treatment","par":[],"pol":"clinical_reasoning_policy_v2","pol_decision":"approved"}
|
||||
1
refimpl/python/tests/__init__.py
Normal file
1
refimpl/python/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Tests package
|
||||
49
refimpl/python/tests/test_config.py
Normal file
49
refimpl/python/tests/test_config.py
Normal file
@@ -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)
|
||||
77
refimpl/python/tests/test_create.py
Normal file
77
refimpl/python/tests/test_create.py
Normal file
@@ -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)
|
||||
98
refimpl/python/tests/test_create_extra.py
Normal file
98
refimpl/python/tests/test_create_extra.py
Normal file
@@ -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"))
|
||||
123
refimpl/python/tests/test_dag.py
Normal file
123
refimpl/python/tests/test_dag.py
Normal file
@@ -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())
|
||||
40
refimpl/python/tests/test_jti_cache.py
Normal file
40
refimpl/python/tests/test_jti_cache.py
Normal file
@@ -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
|
||||
38
refimpl/python/tests/test_ledger_extra.py
Normal file
38
refimpl/python/tests/test_ledger_extra.py
Normal file
@@ -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
|
||||
63
refimpl/python/tests/test_types_extra.py
Normal file
63
refimpl/python/tests/test_types_extra.py
Normal file
@@ -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"
|
||||
63
refimpl/python/tests/test_validate.py
Normal file
63
refimpl/python/tests/test_validate.py
Normal file
@@ -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
|
||||
197
refimpl/python/tests/test_verify.py
Normal file
197
refimpl/python/tests/test_verify.py
Normal file
@@ -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
|
||||
|
||||
|
||||
653
refimpl/python/uv.lock
generated
Normal file
653
refimpl/python/uv.lock
generated
Normal file
@@ -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" },
|
||||
]
|
||||
14
refimpl/testdata/valid_root_ect_payload.json
vendored
14
refimpl/testdata/valid_root_ect_payload.json
vendored
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user