diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 2679bd55..72f58e37 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -259,11 +259,32 @@ jobs: - name: Install notebook execution deps shell: bash - run: pip install --quiet --upgrade pytest nbmake ipykernel highflame cryptography "PyJWT>=2.8" + run: pip install --quiet --upgrade pytest nbmake ipykernel highflame cryptography "PyJWT>=2.8" requests - - name: Execute the quickstart notebook against the live server + # Committed outputs must never contain credential material: raw JWTs, + # zeroid API keys, or private keys. Guards the ODIS walkthroughs' policy + # of printing decoded claims / selected fields only. + - name: Lint committed notebook outputs for credential material shell: bash - run: pytest --nbmake --nbmake-timeout=300 examples/zeroid_quickstart.ipynb -v + # git ls-files (not a shell glob) so a moved/renamed notebook dir can + # never silently disarm the lint: grep exit 2 (error) fails the step + # instead of reading as "no match", and an empty file list is an error. + run: | + files=$(git ls-files 'examples/**/*.ipynb' 'examples/*.ipynb') + if [ -z "$files" ]; then + echo "::error::no committed notebooks found — lint target list is broken"; exit 1 + fi + status=0 + grep -InE 'eyJ[A-Za-z0-9_-]{20,}|zid_(sk|rt)_[A-Za-z0-9_-]{20,}|BEGIN[A-Z ]*PRIVATE KEY' $files || status=$? + if [ "$status" -eq 0 ]; then + echo "::error::credential-looking material found in committed notebook(s)"; exit 1 + elif [ "$status" -ne 1 ]; then + echo "::error::credential lint failed to run (grep exit $status)"; exit "$status" + fi + + - name: Execute the quickstart + ODIS notebooks against the live server + shell: bash + run: pytest --nbmake --nbmake-timeout=300 examples/zeroid_quickstart.ipynb examples/odis/odis-walkthrough.ipynb examples/odis/odis-walkthrough-sdk.ipynb -v - name: Dump zeroid logs on failure if: failure() diff --git a/README.md b/README.md index ab8688f7..1538a90c 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,8 @@ Prefer a runnable walkthrough after installing the SDK? Open the [ZeroID Quickst Want a LangChain-specific intro? Open the [Scope-Aware Tools notebook](examples/langchain/scope_aware_tools.ipynb) to see the same agent gain or lose tool access purely by changing its ZeroID token. +Mapping ZeroID to the [ODIS draft](https://github.com/cosai-oasis/ws4-odis/blob/main/RFCs/ODIS.md) (CoSAI/OASIS WS4)? The [role-capability statement](docs/odis/role-capability-statement.md) grades every Layer 1–2 requirement against code and tests, and the [ODIS walkthrough notebooks](examples/odis/) run the same story — Agent Registration Record → Agent Runtime Credential → Delegation Record → cascade revocation — against a local instance with real outputs. + **Run ZeroID locally** (Docker — 30 seconds): ```bash @@ -955,6 +957,8 @@ References: [OpenID Agentic AI](https://openid.net/wp-content/uploads/2025/10/Id The extensions ZeroID layers on these baseline specs — the additional JWT claims, the CIBA `group_hint` parameter, the DPoP `bh` claim, RFC 8693 attenuation/depth semantics, the WIMSE URI scheme, reserved-claims gating, CAE signal types, and the non-standard endpoints — are specified normatively in [`docs/spec/zeroid-oauth-extensions.md`](docs/spec/zeroid-oauth-extensions.md). +How the whole stack maps onto the CoSAI/OASIS **ODIS** draft (Layers 1–2, native mode), including the requirements ZeroID does *not* meet, is in [`docs/odis/role-capability-statement.md`](docs/odis/role-capability-statement.md), with executable companions in [`examples/odis/`](examples/odis/). + | Standard | RFC / Spec | Used For | |----------|-----------|----------| | OAuth 2.1 | RFC 6749 + BCP | Foundational auth framework | diff --git a/docs/odis/role-capability-statement.md b/docs/odis/role-capability-statement.md new file mode 100644 index 00000000..713e0acb --- /dev/null +++ b/docs/odis/role-capability-statement.md @@ -0,0 +1,317 @@ +# ZeroID — ODIS Role-Capability Statement (Layers 1–2, Native Mode) + +**Status**: Draft for submission to the CoSAI/OASIS WS4 ODIS workstream +**ODIS reference**: [ODIS.md draft](https://github.com/cosai-oasis/ws4-odis/blob/main/RFCs/ODIS.md) (unapproved contributor draft) +**ZeroID version**: commit `054a2dc` on `main` (PR #304's `token.require_dpop`, attestation trust-clamp, and CIBA credential anchoring; PR #318's root-mounted admin API and PR #319's hardening of it; Apache-2.0, ) +**Claim type**: role-capability statement per ODIS §8. **This is not an ODIS Core, Extended, or Safety profile claim** — §8 reserves those for a complete conformance target, and this document itself identifies unmet MUSTs. + +ZeroID is an OAuth 2.1/OIDC authorization server for AI-agent identity, built +independently of ODIS and mapped to it after the fact. It implements the +substance of ODIS **Layer 1 (Passport)** and **Layer 2 (Bridge, native mode)**: +registration records, attestation-gated short-lived proof-of-possession +credentials, delegation records with monotonic attenuation, asynchronous human +authorization, cascade revocation, and dual-identity audit. It does not +implement Layer 3 governance (checkpoint/policy-engine/velocity), bridge-mode +provider adapters, or software/hardware attestation; those are marked **Gap** +below, not argued around. + +## How to read this + +- **Meets** — the requirement's normative substance is implemented and tested. +- **Meets (via configuration)** — a supported configuration of shipped code + fully satisfies the MUST; the note names the exact configuration. ODIS §8 + grades a *configured conformance target*, not a source tree, so this is a + full Meets for any deployment that adopts the named posture. +- **Partial** — implemented with a stated limitation that matters. +- **Gap** — not implemented in the open-source core. Where ZeroID exposes a + deliberate **extension point** for the capability (verifier registry, + notifier hooks, resolver interfaces), the note names it — a deployer or a + commercial distribution can build there, but per §8 an unbuilt extension + carries no conformance evidence, so the verdict stays Gap until code exists. +- **N/A** — the requirement governs a component ZeroID does not contain (stated, not skipped). +- **Evidence** — repo-relative paths and greppable test names. Every pointer in + this document was verified against the commit above; run + `go test ./tests/integration/ -run ` to reproduce any of them + (see `tests/integration/COMPLIANCE.md` for the per-RFC matrix). + +Companion: [`examples/odis/`](../../examples/odis/) — an executable notebook +that walks ODIS terminology (Agent Registration Record → Agent Runtime +Credential → Delegation Record → revocation → audit) against a locally running +ZeroID, with real outputs committed. + +## Summary + +| Layer | Meets | Partial | Gap | N/A | +|---|---|---|---|---| +| L1 — Identity & Attestation (12 reqs) | 9 | 0 | 3 | 0 | +| L2 — Delegation & Access (15 reqs) | 6 | 4 | 3 | 2 | +| L3 — Discovery & Governance (declared out of role; 8 reqs) | 1 | 3 | 4 | 0 | +| CC — Cross-cutting (7 reqs) | 4 | 0 | 2 | 1 | + +The Gap column is the point of publishing: it is implementation feedback on +which ODIS requirements are the real adoption cliff for an OAuth-native +authorization server (software attestation, bridge mode, presenter isolation, +published benchmarks). + +## The map, in ODIS's own figure + +The diagram mirrors the ODIS §3 three-layer architecture figure (same shape, +same layer palette) with ZeroID's components in the boxes. Solid boxes are +implemented, with evidence in the tables below; dashed boxes are the declared +gaps or out-of-role layers. + +```mermaid +flowchart TB + +Agent["Agent runtime\nholds its own holder key"] + +subgraph L1["ODIS Layer 1 — Passport"] +direction LR +WL["Workload attestation\ngeneric OIDC verifier + per-tenant policy\nGitHub Actions · GCP WIF · K8s"] +SW["Software attestation\nL1-02 / L1-08 — GAP"] +HW["Hardware attestation\nL1-04 — GAP"] +TRUST["Trust promotion\nunverified → verified_third_party → first_party\nexactly-once, in-transaction"] +ARC["Agent Runtime Credential\nshort-TTL JWT · DPoP cnf.jkt\natomic jti replay ledger"] +WL --> TRUST --> ARC +SW -.-> ARC +HW -.-> ARC +end + +ARR["Agent Registration Record\nidentity + CredentialPolicy\nWIMSE URI · lifecycle states · accountable owner"] + +subgraph L2["ODIS Layer 2 — Bridge, native mode"] +direction LR +CIBA["Bounded async authorization\nCIBA poll/ping/push + RAR"] +TE["RFC 8693 token exchange\nthree-way scope intersection · act chain\nmission_id · depth cap · expiry clamp"] +DR["Delegation claims\n§6.3 carrier: 6 of 13 fields\nsee field mapping"] +PA["Provider Adapter / bridge mode\nL2-08..10 — GAP\ncompanion broker in development"] +CIBA --> TE --> DR +end + +TARGET["Native-mode target\npkg/authjwt Go · SDK tokens.verify Python\nscope / trust / delegation guards"] +REV["Revocation plane\nCAE signal ingest → cascade via parent_jti\nSSE stream · offboard kill switch"] +L3["ODIS Layer 3 — Router\noutside this statement's role\ngovernance checkpoint · policy engine · velocity"] + +Agent --> L1 +ARC --> L2 +ARR -.->|"L2-14: resolve active registration\nIsUsable at the issuance chokepoint"| L2 +DR --> TARGET +REV -.->|"revocation semantics\nintrospection / event stream"| TARGET +L2 -.-> L3 + +style L1 fill:#8b6914,color:#fff,stroke:#6b5010 +style L2 fill:#4a7c59,color:#fff,stroke:#3a6040 +style Agent fill:#333,color:#fff,stroke:#555 +style WL fill:#a07820,color:#fff,stroke:#806018 +style TRUST fill:#a07820,color:#fff,stroke:#806018 +style ARC fill:#a07820,color:#fff,stroke:#806018 +style CIBA fill:#5a9060,color:#fff,stroke:#4a7050 +style TE fill:#5a9060,color:#fff,stroke:#4a7050 +style DR fill:#5a9060,color:#fff,stroke:#4a7050 +style SW fill:#f5f5f5,color:#333,stroke:#aaa,stroke-dasharray: 5 5 +style HW fill:#f5f5f5,color:#333,stroke:#aaa,stroke-dasharray: 5 5 +style PA fill:#f5f5f5,color:#333,stroke:#aaa,stroke-dasharray: 5 5 +style L3 fill:#f5f5f5,color:#333,stroke:#aaa,stroke-dasharray: 5 5 +style ARR fill:#f0f0f0,color:#333,stroke:#999 +style TARGET fill:#f0f0f0,color:#333,stroke:#999 +style REV fill:#f0f0f0,color:#333,stroke:#999 +``` + +## Layer 1 — The Passport (Identity & Attestation) + +| Req | Verdict | Evidence | Notes | +|---|---|---|---| +| **ODIS-L1-01** Secret-Zero Elimination | **Meets (via configuration)** | Private-key flows: RFC 7523 jwt-bearer (`tests/integration/jwt_bearer_compliance_test.go`), DPoP (`pkg/dpop/`). Static path: `internal/service/oauth.go` `apiKeyGrant`, `domain/credential.go` `GrantTypeAPIKey` | The conformant posture is one policy line: a `CredentialPolicy` whose `AllowedGrantTypes` excludes `api_key` leaves only private-key flows (jwt-bearer, token exchange) on the agent path — fully satisfying the MUST for that deployment. The static `api_key` grant remains in `domain.DefaultAllowedGrantTypes()` as a bootstrap convenience; out-of-the-box defaults are not the conformance target. | +| **ODIS-L1-02** Software Attestation | **Gap** | `domain/attestation.go` declares `image_hash`; only `internal/attestation/stub.go` `DevStubVerifier` backs it | No digest/signature/provenance verification ships in the core (no sigstore/in-toto/SLSA integration). The deliberate extension point exists: the `internal/attestation` `Verifier` registry dispatches by proof type, and `image_hash` is reserved for exactly this — a deployer or distribution binds a real provenance verifier there without core changes. | +| **ODIS-L1-03** Runtime/Workload Attestation | **Meets** | `internal/attestation/oidc.go` (generic `OIDCVerifier`: JWKS discovery, issuer allow-list, audience, `required_claims`, SSRF-guarded transport); per-tenant `AttestationPolicy` (`internal/attestation/policy.go`); fail-closed: `TestAttestationFailsClosedWithNoPolicy`, `TestAttestationOIDCVerifierRejectsUntrustedIssuer`, `TestAttestationDoubleVerifyIsRejected` | One generic OIDC verifier; GitHub Actions, GCP Workload Identity Federation, and Kubernetes projected SA tokens are worked *configurations* (`docs/attestation.md`), not per-provider code. | +| **ODIS-L1-04** Hardware Attestation | **Gap** | `tpm` proof type exists; stub-only | Same status and same extension point as L1-02: bind a TPM/TEE verifier into the registry. | +| **ODIS-L1-05** Credential Lifecycle | **Meets** | `domain/credential_policy.go` (`MaxTTLSeconds`, default 3600), enforcement `internal/service/credential_policy.go` `EnforcePolicy`; child exp clamped to parent (`TestTokenExchangeChildClampedToParentExpiry`); refresh rotation with reuse-detection family revocation (`tests/integration/refresh_token_race_test.go`) | Lifetimes are configurable, finite, policy-bounded. Automatic rotation before expiry is enforced by the SDK component of the declared target: the token manager refreshes ahead of expiry via a built-in buffer (highflame-sdk 0.3.23 (separate repo): `highflame/zeroid/_token_manager.py`, `_TOKEN_REFRESH_BUFFER`), with the server re-gating identity state and current policy on every rotation. | +| **ODIS-L1-06** Provisioning & De-provisioning | **Meets** | Lifecycle states `discovered→pending→active→suspended→deactivated/expired` (`domain/identity.go` `CanTransitionTo`); adopt/dismiss (`internal/handler/identity.go`); offboard-by-owner cascade (`internal/service/identity.go` `OffboardOwner`, DB `revoke_credentials_by_owner`); `tests/integration/discovery_lifecycle_test.go` | Satisfied via the "equivalent lifecycle-management mechanisms" clause — SCIM protocol endpoints themselves are not implemented. | +| **ODIS-L1-07** Federated Trust *(Extended)* | **Meets** | Direct external-issuer OIDC federation: `external_issuers` config (`domain/external_issuer.go`), verification `internal/service/oauth_external_idp.go` (`subject_token_type=…:id_token`), upstream `iss` propagated as `user_id_iss`; `TestExternalIDTokenFederation_EndToEnd`, `_KeyRotation`, `_CrossTenantRejected` | Deployer-configured trust anchors; no automatic federation discovery. A trusted-service broker path also exists (`ExternalPrincipalExchange`) and is documented as the lossier fallback. | +| **ODIS-L1-08** Trusted Distribution | **Gap** | — | No supply-chain verification before identity issuance. Pairs with L1-02. | +| **ODIS-L1-09** Holder-of-Key Authentication | **Meets (via configuration)** | DPoP with atomic jti replay ledger (`pkg/dpop/verifier.go`, `TestDPoPReplayRejected`); refresh tokens key-bound (`TestDPoPRefreshBoundWithDifferentKeyRejected`); binding propagates through delegation (`TestDPoPTokenExchange_PropagatesBindingToSubAgent`); WIMSE single-use nonce-bound proof tokens (`internal/service/proof.go`); **enforcement switch**: `token.require_dpop` refuses proof-less issuance with `invalid_dpop_proof` on every grant and advertises `dpop_bound_access_tokens_required` per RFC 9449 §5.1 (`TestRequireDPoPRefusesProoflessIssuance`, `TestRequireDPoPAdvertisedInASMetadata`) | The conformant posture is `token.require_dpop: true` (default off preserves Bearer opt-in): every issued credential is then proof-of-possession-bound. Client-side note: the Python SDK (0.3.23) does not yet construct DPoP proofs (tracked upstream, sdk#105), so a require_dpop deployment currently serves raw-HTTP/self-signing clients — the notebook demonstrates proof construction in a few lines of PyJWT. Consequently the SDK companion notebook runs under the default `require_dpop: false`; each notebook's required configuration is stated in `examples/odis/README.md`. | +| **ODIS-L1-10** Accountable Sponsor | **Meets** | `owner_user_id` verified against the tenant directory (CAP-DSC-004, `internal/service/identity.go`); offboard-by-owner cascade; ownerless as a surfaced posture signal | ODIS's administrative *drain* state is not implemented — lifecycle events revoke rather than drain. | +| **ODIS-L1-11** Attestation-Bootstrapped Trust | **Meets (via configuration)** | Attestation raises `trust_level` exactly-once in-transaction (`internal/service/attestation.go` `trustLevelForAttestation`); `CredentialPolicy.RequiredTrustLevel`/`RequiredAttestation` gate issuance (`EnforcePolicy` checks 4–5); expiry demotes (`TestExpiredAttestationNoLongerSatisfiesPolicy`) | The conformant posture: `RequiredTrustLevel`/`RequiredAttestation` on the identity's `CredentialPolicy` makes issuance depend on verified attestation, fail-closed (demonstrated live in the companion notebook, §3–§4). Tenants that omit the requirement have chosen a non-ODIS posture; the enforcement machinery is shipped and tested. | +| **ODIS-L1-12** Runtime Security State | **Meets** | Signal ingest `POST /signals/ingest` (`internal/handler/signal.go`); severity-driven revocation with cascade (`TestCAECriticalSignalRevokesCredential`, `TestCAESignalCascadesRevocationToChildren`) | Signal schema is ZeroID-local, not CAEP event-type URIs (see `COMPLIANCE.md`'s honest SSF/CAEP row). | + +## Layer 2 — The Bridge (Delegation & Access, native mode) + +| Req | Verdict | Evidence | Notes | +|---|---|---|---| +| **ODIS-L2-01** Delegated Authorization | **Partial** | Three-way scope intersection at exchange (`internal/service/oauth.go` `tokenExchange`: requested ∩ subject-granted ∩ actor-policy); empty intersection fails closed (`invalid_scope`) | Partial under a strict reading: the MUST enumerates the intersection's inputs as principal ∩ registration ∩ parent ∩ **task ∩ resource ∩ environmental constraints** ∩ adapter mapping. ZeroID intersects scopes/depth/TTL, resolves the registration, clamps to the parent, and re-verifies the principal on refresh — but models no task/resource/constraint dimensions. Fail-closed behavior itself is complete. | +| **ODIS-L2-02** Bounded Authorization | **Meets** | CIBA poll/ping/push (`internal/service/backchannel.go`, `TestCIBACore1_0_S11_AuthorizationPendingWhilePending`); RAR typed `authorization_details` bound into the approval and the issued JWT (`TestRFC9396_S6_1_AccessTokenJWTEmbedsAuthorizationDetails`, `ciba_rar_test.go`) | Async human approval bound to the request's declared authority — ODIS's headless-agent clause implemented with standard protocols. | +| **ODIS-L2-03** Session Continuity | **Meets** | Refresh re-gates identity usability before *and* after rotation (`internal/service/oauth.go` `(*OAuthService).refreshToken`); DPoP binding re-checked; reuse detection revokes the family (`refresh_token_race_test.go`) | Fail-closed on revocation, deactivation, expiry, or binding mismatch. | +| **ODIS-L2-04** Durable Delegation *(Extended)* | **Meets** | Refresh tokens with rotation + reuse-detection (`refresh_token_race_test.go`); every refresh re-gates identity state and re-runs current policy | This is the SHOULD's substance: a pre-authorized window in which authority auto-renews without new human interaction, bounded by lifecycle state, policy, and revocation — the window construct is the refresh-token family. | +| **ODIS-L2-05** Delegation Record | **Partial** | `act` chain (`domain/token.go` `ActorClaims`, `TestRFC8693_S4_2_ActClaimChainsDelegation`), `parent_jti`, `mission_id` lineage (`TestMissionID_ChainPropagation`), `delegation_depth`, child expiry clamped to parent | Carried as JWT claims (§6.3 permits any integrity-protected carrier; integrity = the AS signature), but several §6.3 MUST fields have no equivalent — see the field mapping below the table. | +| **ODIS-L2-06** Authorization Attenuation | **Partial** | Monotonic narrowing via set intersection over a controlled scope vocabulary (an out-of-ceiling request is narrowed to the intersection, and refused only when nothing survives — `internal/service/oauth.go` token-exchange Step 4); depth cap; `TestRFC8693_*` + `subagent_delegation_test.go` | This is lexical-with-controlled-vocabulary, not the semantic `attenuation_profile_ref` mechanism ODIS specifies. Honest reading: sufficient when the issuer owns the vocabulary, insufficient for cross-vendor scope semantics. | +| **ODIS-L2-07** Contextual Re-verification | **Meets** | Pre-rotation identity gate + full `EnforcePolicy` re-run against *current* policy on every refresh (`(*OAuthService).refreshToken`; `deactivation_test.go`) | | +| **ODIS-L2-08** Backward Compatibility (bridge) | **Gap** | — | ZeroID is native-mode only: downstream services validate ZeroID JWTs (`pkg/authjwt`). Target-native credential translation is a companion credential-broker component under development, out of this statement's scope. | +| **ODIS-L2-09** Bridge Mapping | **Gap** | — | With L2-08. | +| **ODIS-L2-10** Fail-Closed Attenuation (bridge) | **Gap** | — | With L2-08. | +| **ODIS-L2-11** Revocation-Safe Credential Reuse | **N/A** | Within its own token plane: cascade revocation + `RevocationNotifier` fire per-JTI (`TestRevocationNotifier_FiresOncePerJTIOnCascade`) | ZeroID does not cache derived downstream credentials, so the reuse-TTL bound has no object here; it binds the (future) broker component. | +| **ODIS-L2-12** Presenter Continuity | **Meets** | Same-key enforcement across the credential's whole life: refresh rotation rejects a different holder key (`TestDPoPRefreshBoundWithDifferentKeyRejected`), refresh without proof rejected (`TestDPoPRefreshBoundWithoutProofRejected`), and the binding propagates through delegation (`TestDPoPTokenExchange_PropagatesBindingToSubAgent`) | The requirement is conditional ("*when* Layer 2 issues a holder-of-key-bound credential") and ZeroID enforces exactly its substance for every bound credential: no key substitution, no export-and-rebind without re-issuance. Key custody location is L2-13/Pattern-4 territory, not this row. | +| **ODIS-L2-13** Presenter Authority Scoping | **Partial** | The WIMSE proof service is the non-generic presenter primitive the MUST describes: fixed claim shape (never arbitrary payloads), audience-bound, single-use via DB-unique nonce, and identity-state-gated before construction (`internal/service/proof.go` — `IsUsable`/expiry checked before signing; `TestProofTokenSingleUseUnderConcurrency`) | What's missing: action-level validation against the delegation record's authority, and routing DPoP proof construction through a scoped presenter — today DPoP proofs are client-constructed with directly-held keys (Pattern 4). This is exactly the surface ODIS's pending CT-P4 suite exists for — feedback for the WG: SDK-first implementations need it defined. | +| **ODIS-L2-14** Agent Registration Resolution | **Meets** | `Identity.IsUsable()` gates 15 call sites including the issuance chokepoint (`internal/service/credential.go`); `discovered` rows are inert inventory by construction (`domain/identity.go`, `discovery_lifecycle_test.go`) | ODIS's "resolve to an *active* registration before authority" is structural here, not a check bolted on. | +| **ODIS-L2-15** Provider Adapter Egress Mode | **N/A** | — | No adapters; native mode implicit. | + +### §6.3 Delegation Record — field-by-field against ZeroID's claims + +| §6.3 MUST field | ZeroID equivalent | Status | +|---|---|---| +| `delegation_id` | `jti` | present | +| `issuer` | `iss` (the AS) | present | +| `parent_delegation_ref` | `parent_jti` — but no `record_digest`; the parent is resolved by ID, digest-matching is not performed | partial | +| `originating_principal` | `act.sub` / `owner_user_id` when a human roots the chain | partial (machine-rooted chains carry no distinct originating principal) | +| `originating_authorization_ref` | — | absent | +| `actor` | `sub` (WIMSE URI) | present | +| `delegation_chain` | single-level `act` + reconstruction via `/delegations/by-jti` — the record itself does not carry the ordered hop list | partial | +| `task_id` | `mission_id` — a chain **correlation** key, not a declared purpose | partial (see zeroid#222: link mission_id to human-authored intent) | +| `granted_authorizations` | `scope` after intersection | present | +| `resource_indicators` | — (RFC 8707 `resource` unsupported; open zeroid#258) | absent | +| `constraints` | — | absent | +| `attenuation_profile_ref` | — (the L2-06 gap) | absent | +| `issued_at` / `expires_at` | `iat` / `exp`, child clamped to parent | present | + +This mapping is offered to the workstream as implementation feedback: an +OAuth-native carrier gets 6 of 13 fields for free, 3 partially, and the 4 +absent ones (`originating_authorization_ref`, `resource_indicators`, +`constraints`, `attenuation_profile_ref`) are exactly the fields with no +established OAuth claim to inherit — candidates for a minimal JWT claim +profile the spec could publish. + +## Layer 3 — The Router (outside this statement's role, stated anyway) + +| Req | Verdict | Evidence / note | +|---|---|---| +| ODIS-L3-01 Tool/Service Discovery | **Partial** — observed-resource registry learned from ID-JAG redemptions, deliberately evidence-not-assertion (`internal/handler/observed_resources.go`) | +| ODIS-L3-02 Governance Checkpoint | **Gap** — belongs to a gateway/router component, not the AS | +| ODIS-L3-03 Velocity Limits | **Gap** — a dormant `rate_limit_rps` column exists and is read nowhere; CIBA `slow_down` is protocol pacing, not rate limiting | +| ODIS-L3-04 Revocation Latency | **Partial** — the *mechanism* is synchronous cascade in-transaction + SSE push (`GET /signals/stream`) + `RevocationNotifier` hooks; a declared, measured maximum latency (what ODIS actually requires) is not published — see CC-03 | +| ODIS-L3-05 Kill Switch | **Meets** — identity deactivation and offboard-by-owner cascade-revoke all credentials in one operation (`revoke_credentials_by_owner`, `TestCAESignalRevokesAllActiveCredentials`) | +| ODIS-L3-06 Policy Engine Integration | **Gap** — no OPA/Cedar callout in ZeroID's request path; ID-JAG maps IdP claims into Cedar-shaped principal attributes for *downstream* engines (`oauth_id_jag.go`), which is claim shaping, not checkpoint integration. The §6.4 object itself is assemblable from existing APIs — see the inter-layer payload appendix | +| ODIS-L3-07 Task-Bound Tokens *(Extended)* | **Partial** — `mission_id` (delegation-tree purpose anchor) and RAR `authorization_details` carry declared intent in the token; no checkpoint validates actions against it inside ZeroID | +| ODIS-L3-08 Boundary Protection *(Extended)* | **Gap** — with L3-02 | + +## Cross-cutting + +| Req | Verdict | Evidence | Notes | +|---|---|---|---| +| **ODIS-CC-01** Observability | **Meets** | Identity audit tables + trigger (`migrations/010`, `012`); delegation explorer `/delegations/graph`, `/by-jti/{jti}`, `/chains` (`internal/handler/delegation.go`, 33 tests incl. `TestDelegationByJTI_WalksToRoot`); credentials retained past expiry on a dual-clock sweeper (`internal/worker/cleanup.go`) so lineage survives for forensics | Audit logs are not hash-chained (a SHOULD; MUST only in the Safety profile). | +| **ODIS-CC-02** Dual-Identity Audit Trail | **Meets** | Tokens carry agent `sub` + `act.sub` (originating human — preserved even through key rotation, commit `220321f`) + `owner_user_id`; `TestRFC8693_S4_2_ActClaimChainsDelegation` | | +| **ODIS-CC-03** Latency Budget | **Gap** | No `Benchmark*` functions, no published p50/p95/p99 report anywhere in-repo | ODIS requires a *published, reproducible* benchmark report; none exists. | +| **ODIS-CC-04** Availability | **Gap** | No published availability objective/measurement | With CC-03. | +| **ODIS-CC-05** Governed Identity Creation | **Meets** | Registration via authenticated admin surface; discovered rows require human adopt before usability; agents cannot mint or expand their own registration (`AdoptIdentity`, transition matrix) | | +| **ODIS-CC-06** Terminal Exchange Audit Anchor | **N/A** | ID-JAG redemptions record observed resources with single-use jti enforcement (`internal/store/postgres/id_jag_jti.go`) | Terminal exchange into non-ODIS systems is the broker component's obligation, not the AS's. | +| **ODIS-CC-07** Data Protection | **Meets** | Query strings stripped from access logs (`logSafePath`, `log_redaction_test.go` (root package — `go test . -run TestLogSafePath`)); OAuth error-value redaction on token/introspect/revoke/bc-authorize (`internal/handler/routes.go` `redactErrorValues`); raw connector/API secrets never persisted (hashed/ referenced); configurable audit retention with deletion sweeper | | + +## Appendix — the inter-layer payload contract (§6.1 / §6.2 / §6.4) + +ODIS mandates no wire format ("JWT claims, protocol buffers, JSON-LD" are all +valid carriers) but does mandate what each layer hands the next: the Passport +emits an Agent Runtime Credential Descriptor (§6.2) that must reference the +Agent Registration Record (§6.1); the Bridge emits the Delegation Record +(§6.3 — mapped field-by-field above); and the governance checkpoint must be +fed the assembled Identity Context (§6.4), which bundles all three plus the +requested action. Each payload carries an authenticated back-reference to the +one before it. This appendix maps the remaining two. + +A note on field names: §6's schemas are **abstract** — a `credential_id` row +matched to `jti` below is not a mismatch but a *binding*, per the spec's own +carrier rule. What the spec requires is the field's semantics +("collision-resistant identifier for this Agent Runtime Credential"), and +RFC 7519's `jti` is the registered JWT claim carrying exactly those +semantics. ZeroID's bindings are documented normatively in its claim +registry (`docs/spec/zeroid-oauth-extensions.md` §14). The interoperability +risk sits one level up: ODIS publishes no canonical JWT binding, so two +conformant implementations could bind the same abstract field to different +claims — see feedback item 3. + +### §6.2 Agent Runtime Credential Descriptor — field-by-field + +| §6.2 field | ZeroID equivalent | Status | +|---|---|---| +| `credential_id` | `jti` | present | +| `format_version` | — (RFC 9068 `at+jwt` typ header is open work, zeroid#189) | absent | +| `agent_id` | `sub` (WIMSE URI) | present | +| `registration_record_ref` (issuer + id + version + digest) | registration resolved by authoritative same-database lookup at issuance *and* refresh (`IsUsable()` chokepoint) — within a single trust domain this is strictly stronger than a digest reference (always-current, no staleness window). The portable versioned ref matters only for cross-domain verification (federation), which is not this binding's deployment shape | present (co-located binding) | +| `runtime_instance_id` | — (ZeroID models the logical agent; per-issuance `jti` is the closest analogue) | absent | +| `software_hash` | — (the L1-02 gap, carried into the payload) | absent | +| `attestation_evidence[]` | `attestation_records` exist server-side and gate trust, but no evidence object is carried in or referenced by the credential | absent | +| `issuer` / `issuer_key_ref` | `iss` / JOSE `kid` against the published JWKS | present | +| `holder_key_ref` | `cnf.jkt` when DPoP-bound | present under the `token.require_dpop` posture (every credential bound); opt-in with Bearer fallback otherwise | +| `issued_at` / `expires_at` | `iat` / `exp` | present | +| `trust_domain` | the WIMSE URI's trust-domain segment | present | +| `supply_chain_ref` *(SHOULD)* | — | absent | +| `audiences` | `aud` — defaults to the issuer; RFC 8707 resource binding is open work (zeroid#199/#258) | partial | + +### §6.4 Identity Context — assembly feasibility + +ZeroID emits no assembled §6.4 object (the L3-06 Gap above). The raw material +is another story — nearly every field already exists behind an API: + +| §6.4 field | ZeroID raw material | Status | +|---|---|---| +| `agent_registration` (§6.1 object) | `GET /identities/{id}` + its `CredentialPolicy` | data exists, unassembled | +| `agent_runtime` (§6.2 descriptor) | RFC 7662 introspection (`act`, `cnf`, `trust_level`, `delegation_depth`, `authorization_details`) | data exists, §6.2 gaps carry over | +| `delegation` (§6.3 record) | `GET /delegations/by-jti/{jti}` — lineage, scopes in/out, attenuation, revocation | data exists (partial per the §6.3 mapping) | +| `action` {tool, method, resource, parameters} | supplied by the governance checkpoint at call time, not the AS | checkpoint-side by design | +| `request_timestamp` | checkpoint-assigned per §6.4 ("MUST NOT rely solely on a timestamp supplied by the agent") | checkpoint-side by design | +| `request_trace_id` | server-side `request_id` in logs only; nothing end-to-end in the token or introspection | gap | +| `runtime_risk_signals[]` | `GET /signals` (CAE signal store, typed + severities) | data exists | + +**The integration seam this exposes:** a read-only +`GET /identity-context/{jti}` that joins what ZeroID already stores would make +it the first implementation emitting §6.4 — and that object is precisely the +input the ODIS contract harness's Router/OPA checkpoint wants to be fed. One +endpoint turns "ZeroID = Layers 1–2" and "harness = Layer 3" into a running +three-layer ODIS stack. + +## Feedback to the ODIS workstream (what this mapping surfaced) + +1. **The adoption cliff is L1-02/L1-08 + L2-08..10 + L2-13.** An OAuth-native + AS can meet the identity, delegation, lifecycle, revocation, and audit + requirements of Layers 1–2 with standard protocols (RFC 7523/8693/9396/9449, + CIBA, SPIFFE-style naming). Software/supply-chain attestation, bridge-mode + translation, and presenter isolation are each a separate engineering + program. Sequencing guidance in the spec (which Core MUSTs make a useful + Phase-1) would materially help implementers. +2. **CT-P4 is load-bearing.** Every SDK-first implementation lands in + deployment Pattern 4; until the CT-P4 suite exists, none can claim Core + regardless of how much else they meet. Defining it should be a priority — + we volunteer as a test subject. +3. **Publish a canonical JWT binding for §6.2/§6.3, and a minimal + attenuation profile for L2-06.** The schemas are abstract by design, but + with no canonical claim binding, two conformant implementations can bind + the same field to different claims (`credential_id` → `jti` here; a + custom claim elsewhere) and verifiers cannot converge — a short + abstract-field → registered-JWT-claim table would fix it. Likewise + L2-06's `attenuation_profile_ref` has no example profile anywhere; our + lexical-with-controlled-vocabulary intersection is what implementers + build first, and a canonical minimal profile (exact-match over a + registered vocabulary) would let such implementations declare themselves + precisely. +4. **agent_id vs runtime instance** (their issue #6): ZeroID's split — stable + logical identity + WIMSE URI, per-issuance short-lived credentials with + per-JTI lineage — is a working answer to the uniqueness question and is + documented in `docs/spec/zeroid-oauth-extensions.md` §14 (claim registry). +5. **Platform-credential verifiability** (their issue #5): the + `AttestationPolicy` shape (per-tenant issuer allow-list + `required_claims` + + audience, one generic OIDC verifier) is a concrete data model for the + verification-method metadata that issue asks about. +6. **The §6.4 Identity Context is the natural join point between + implementations.** ZeroID holds nearly every §6.4 field behind existing + APIs (see the appendix); a single read-only assembly endpoint would emit + the first real §6.4 object — and feed it straight into the contract + harness's Router/OPA checkpoint for an end-to-end three-layer + demonstration. We propose building exactly that as the first joint + artifact. + +## Known hardening items surfaced while writing this statement + +Recorded here for honesty; tracked in the ZeroID issue tracker: + +- `attestation.allow_unsafe_dev_stub` currently defaults to **true**, wiring a + stub verifier for the `image_hash`/`tpm` proof types (the OIDC verifier is + never stubbed). The default should flip to false. +- `service_keys.rate_limit_rps` exists in schema and domain but is read + nowhere (relates to L3-03). diff --git a/examples/odis/README.md b/examples/odis/README.md new file mode 100644 index 00000000..dd750ab0 --- /dev/null +++ b/examples/odis/README.md @@ -0,0 +1,85 @@ +# ZeroID as ODIS Layers 1–2 + +Executable walkthroughs of ZeroID using the vocabulary of the +[ODIS draft](https://github.com/cosai-oasis/ws4-odis/blob/main/RFCs/ODIS.md) +(Open Delegation & Identity Standard, CoSAI/OASIS WS4): Agent Registration +Record → attestation-gated trust → DPoP-bound Agent Runtime Credential → +Delegation Record with monotonic attenuation → compromise-signal cascade +revocation → audit lineage. + +Two notebooks, two vantage points: + +- **[`odis-walkthrough.ipynb`](./odis-walkthrough.ipynb)** — raw HTTP, the + wire-format view: what the *authorization server* enforces at issuance + (policy-gated fail-closed issuance, DPoP holder binding and replay + rejection, attestation-driven trust promotion, the delegation explorer). +- **[`odis-walkthrough-sdk.ipynb`](./odis-walkthrough-sdk.ipynb)** — the + Python SDK (`pip install highflame`), adding the *ODIS-aware target* view + (ODIS §2.5 native mode): local JWKS verification with typed guards + (`require_scope` / `require_trust` / `is_delegated`), target-side trust + gating, attenuation's two modes (silent narrowing vs. refusal), and why + native-mode targets must check revocation state, not just signatures. + +Companion to the +[role-capability statement](../../docs/odis/role-capability-statement.md), +which maps every ODIS requirement to code and tests — including the ones +ZeroID does not meet. + +## Run it + +From the repo root: + +```bash +make setup-keys # ES256 + RSA signing keys into ./keys +docker compose up -d # zeroid + postgres on localhost:8899 +pip install requests pyjwt cryptography jupyter +jupyter notebook examples/odis/odis-walkthrough.ipynb +``` + +The SDK notebook additionally needs `pip install highflame` (latest — CI executes +it against the latest release on every PR; the committed outputs were generated with +highflame 0.3.23). + +## Which configuration each notebook runs under + +Both notebooks execute against the stock compose deployment — in particular +`token.require_dpop: false`, the default: + +- The raw-HTTP walkthrough **chooses** the default so §5 can show the Bearer + fallback and the configuration switch that closes it; every DPoP behavior + (holder binding, replay rejection) is still demonstrated live. +- The SDK walkthrough **requires** the default: the Python SDK (0.3.23) cannot + construct DPoP proofs yet (highflame-sdk#105), so under `require_dpop: true` + — the hardened posture the role-capability statement grades L1-09 against — + its issuance calls are refused with `invalid_dpop_proof`. + +The committed outputs contain no credential material: cells print decoded +claims and selected fields, never raw tokens, API keys, or private keys. CI +enforces this with a credential-material lint and re-executes both notebooks +against a fresh server on every PR (`highflame-notebook-check`). + +Upgrading a checkout whose postgres volume predates the CIBA grant joining +the default credential policy? Current zeroid heals stored default policies +on startup (migration `044_default_policy_add_ciba_grant`); if you are +running an older build, `docker compose down -v` for a fresh volume. + +The notebook is re-runnable: every run registers fresh identities under a +random suffix. The committed outputs are from a real run against a local +instance — if you re-execute, tokens, JTIs, and timings will differ; the +status codes and semantics will not. + +## What it demonstrates (and what it deliberately doesn't) + +Demonstrated live: fail-closed issuance for unattested identities +(ODIS-L2-14/L1-11), DPoP proof-of-possession with replay rejection +(L1-05/L1-09), three-way scope intersection and refused privilege escalation +at delegation (L2-01/05/06), critical-signal cascade revocation in +milliseconds (L1-12/L3-04/05), and delegation lineage that survives the kill +(CC-01/02). + +Not demonstrated, because ZeroID does not implement it: software/supply-chain +attestation, bridge-mode provider adapters, presenter isolation, velocity +limits. The role-capability statement documents those gaps with the same +candor. The local run also uses the dev-stub attestation proof (`image_hash`) +instead of the production OIDC workload verifier, which needs a real issuer +(GitHub Actions / GCP WIF / Kubernetes) — see `docs/attestation.md`. diff --git a/examples/odis/odis-walkthrough-sdk.ipynb b/examples/odis/odis-walkthrough-sdk.ipynb new file mode 100644 index 00000000..6e24b722 --- /dev/null +++ b/examples/odis/odis-walkthrough-sdk.ipynb @@ -0,0 +1,486 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "48e274d2", + "metadata": {}, + "source": [ + "# ZeroID as ODIS Layers 1–2 — the SDK companion\n", + "\n", + "Companion to [`odis-walkthrough.ipynb`](./odis-walkthrough.ipynb) (raw HTTP, wire-format\n", + "view) and the [role-capability statement](../../docs/odis/role-capability-statement.md).\n", + "This notebook replays the same ODIS story through the **Python SDK**\n", + "(`pip install highflame`), which shifts the vantage point in one important way:\n", + "\n", + "- The raw notebook shows the **authorization server** enforcing ODIS at issuance.\n", + "- This notebook also shows the **ODIS-aware target** (ODIS §2.5 \"native-mode downstream\n", + " path\", L2-15): `client.tokens.verify()` validates the Agent Runtime Credential *locally*\n", + " against the JWKS — exactly what a native-mode resource server does — and the returned\n", + " identity carries typed guards (`require_scope`, `require_trust`, `is_delegated`) that a\n", + " tool server calls before executing anything.\n", + "\n", + "**Prerequisites** (repo root): `make setup-keys && docker compose up -d`, then\n", + "`pip install highflame pyjwt cryptography` (the committed outputs were generated with highflame 0.3.23).\n", + "\n", + "> **Configuration note (ODIS-L1-09):** this notebook requires the compose default\n", + "> `token.require_dpop: false`. The Python SDK cannot construct DPoP proofs yet\n", + "> (highflame-sdk#105), so under the hardened configuration the role-capability statement\n", + "> grades as L1-09 *Meets (via configuration)*, every issuance call below is refused with\n", + "> `invalid_dpop_proof`. The DPoP-bound path is demonstrated over raw HTTP in the main\n", + "> walkthrough.\n", + ">\n", + "> **Local dev trust model:** admin-plane SDK calls (`identities.update`, `signals.ingest`,\n", + "> …) authenticate here only by client-supplied tenant IDs — a dev-mode convenience.\n", + "> Production fronts the admin plane with authenticated sessions and derives tenancy from\n", + "> verified claims." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "0f530ece", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-04T20:42:25.133609Z", + "iopub.status.busy": "2026-09-04T20:42:25.133448Z", + "iopub.status.idle": "2026-09-04T20:42:25.137878Z", + "shell.execute_reply": "2026-09-04T20:42:25.137255Z" + } + }, + "outputs": [], + "source": [ + "# Uncomment the next line to install the prerequisites into this kernel (latest SDK release),\n", + "# then restart the kernel before running the next cell. Left unquiet on purpose: if pip\n", + "# hits a dependency conflict in your environment you want to see it, not a silent no-op.\n", + "# %pip install --upgrade highflame \"PyJWT[crypto]>=2.13\" cryptography" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "6114ccb0", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-04T20:42:25.139831Z", + "iopub.status.busy": "2026-09-04T20:42:25.139662Z", + "iopub.status.idle": "2026-09-04T20:42:25.259279Z", + "shell.execute_reply": "2026-09-04T20:42:25.258776Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "status='healthy' service='zeroid' timestamp=datetime.datetime(2026, 9, 4, 20, 42, 25, 255728, tzinfo=TzInfo(0)) uptime_ms=711126 | run id: 059ce3\n" + ] + } + ], + "source": [ + "import time, uuid, jwt\n", + "from cryptography.hazmat.primitives.asymmetric import ec\n", + "from cryptography.hazmat.primitives import serialization\n", + "from highflame.zeroid import ZeroIDClient\n", + "\n", + "client = ZeroIDClient(base_url=\"http://localhost:8899\",\n", + " account_id=\"acct-sdk-demo\", project_id=\"proj-sdk-demo\")\n", + "run = uuid.uuid4().hex[:6]\n", + "print(client.health(), \"| run id:\", run)" + ] + }, + { + "cell_type": "markdown", + "id": "d38bde89", + "metadata": {}, + "source": [ + "## 1 · Agent Registration Record — Layer 1 · The Passport (ODIS §6.1)\n", + "\n", + "`agents.register` creates the durable governance record and returns the bootstrap API key\n", + "in one call. The agent is born `unverified` with a stable WIMSE URI (ODIS's `agent_id`).\n", + "\n", + "> **SDK coverage note:** the attestation-gated, fail-closed issuance demo (a policy\n", + "> *refusing* to issue) lives in the raw-HTTP companion; this notebook stays on the\n", + "> target-side view and uses a policy only as the delegation ceiling in §4." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "7ee7c8fd", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-04T20:42:25.260508Z", + "iopub.status.busy": "2026-09-04T20:42:25.260419Z", + "iopub.status.idle": "2026-09-04T20:42:25.272167Z", + "shell.execute_reply": "2026-09-04T20:42:25.271806Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "spiffe://highflame.ai/acct-sdk-demo/proj-sdk-demo/agent/orch-059ce3 | trust: unverified | status: active\n" + ] + } + ], + "source": [ + "reg = client.agents.register(name=\"Orchestrator\", external_id=f\"orch-{run}\",\n", + " sub_type=\"orchestrator\", created_by=\"demo-admin@example.com\",\n", + " allowed_scopes=[\"data:read\", \"data:write\"])\n", + "orch = reg.identity\n", + "print(orch.wimse_uri, \"| trust:\", orch.trust_level, \"| status:\", orch.status)" + ] + }, + { + "cell_type": "markdown", + "id": "a38f7014", + "metadata": {}, + "source": [ + "## 2 · Agent Runtime Credential — the Passport's output (ODIS §6.2, L1-05)\n", + "\n", + "Short-lived, scoped issuance from the bootstrap key. (The DPoP holder-binding variant is in\n", + "the raw companion; the SDK issues bearer tokens today.)" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "88968c72", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-04T20:42:25.273401Z", + "iopub.status.busy": "2026-09-04T20:42:25.273329Z", + "iopub.status.idle": "2026-09-04T20:42:25.281299Z", + "shell.execute_reply": "2026-09-04T20:42:25.280879Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Bearer | expires_in: 3600 s | scope: data:read data:write\n" + ] + } + ], + "source": [ + "token = client.tokens.issue_api_key(reg.api_key, scope=\"data:read data:write\")\n", + "print(token.token_type, \"| expires_in:\", token.expires_in, \"s | scope:\", token.scope)" + ] + }, + { + "cell_type": "markdown", + "id": "9ba45386", + "metadata": {}, + "source": [ + "## 3 · The ODIS-aware target — the Bridge's native mode (ODIS §2.5 / L2-15)\n", + "\n", + "ODIS §2.5 defines a native-mode target as one that independently validates the Agent\n", + "Runtime Credential and the active Delegation Record and enforces their **audience, holder\n", + "binding, attenuation, constraints, freshness, and revocation** semantics. This section\n", + "demonstrates the validation + attenuation/trust slice of that duty list; §5 covers\n", + "revocation. Audience enforcement is demonstrated below; holder-binding verification at the\n", + "target (checking a DPoP proof against `cnf.jkt`) is what ZeroID's Go `pkg/dpop` provides\n", + "resource servers — not yet surfaced in the Python SDK.\n", + "\n", + "`tokens.verify()` performs the local JWKS validation — no round-trip to the authorization\n", + "server. One caution before leaning on it: local verification is **revocation-blind** — a\n", + "token the server has already revoked keeps verifying until it expires. §5 demonstrates that\n", + "divergence and what a native-mode target must add. The typed guards are the enforcement. Note the\n", + "trust gate fires at the *target*, complementing the issuance-side gate the raw notebook\n", + "shows: the agent holds a perfectly valid token, and the tool server still refuses it until\n", + "the identity is trusted enough." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "18052f25", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-04T20:42:25.282398Z", + "iopub.status.busy": "2026-09-04T20:42:25.282316Z", + "iopub.status.idle": "2026-09-04T20:42:25.288160Z", + "shell.execute_reply": "2026-09-04T20:42:25.287687Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "verified: spiffe://highflame.ai/acct-sdk-demo/proj-sdk-demo/agent/orch-059ce3 | delegated: False\n", + "refused (ZeroIDError): Missing required scope: 'admin:all'\n", + "refused (ZeroIDError): Insufficient trust level: required 'first_party', got 'unverified'\n" + ] + } + ], + "source": [ + "identity = client.tokens.verify(token.access_token) # local JWKS validation\n", + "print(\"verified:\", identity.sub, \"| delegated:\", identity.is_delegated())\n", + "\n", + "identity.require_scope(\"data:read\") # passes silently\n", + "for check in (lambda: identity.require_scope(\"admin:all\"),\n", + " lambda: identity.require_trust(\"first_party\")):\n", + " try:\n", + " check()\n", + " except Exception as e:\n", + " print(f\"refused ({type(e).__name__}):\", e)" + ] + }, + { + "cell_type": "markdown", + "id": "801f3872", + "metadata": {}, + "source": [ + "Audience is the first duty on ODIS §2.5's native-mode list, and it is one argument\n", + "here: a target passes the audience it serves, and `verify` refuses tokens minted for anyone\n", + "else. (ZeroID stamps `aud` on every credential per JWT-SVID §3, defaulting to the issuer URL\n", + "when the requester names no audience.)" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "b720f3f2", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-04T20:42:25.289147Z", + "iopub.status.busy": "2026-09-04T20:42:25.289079Z", + "iopub.status.idle": "2026-09-04T20:42:25.291793Z", + "shell.execute_reply": "2026-09-04T20:42:25.291323Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "token aud: ['http://localhost:8899']\n", + "wrong audience refused (ZeroIDError): Token verification failed: Audience doesn't match\n" + ] + } + ], + "source": [ + "aud = jwt.decode(token.access_token, options={\"verify_signature\": False})[\"aud\"] # display-only peek\n", + "print(\"token aud:\", aud)\n", + "client.tokens.verify(token.access_token, audience=aud[0]) # expected audience: passes\n", + "try:\n", + " client.tokens.verify(token.access_token, audience=\"https://some-other-service.example\")\n", + "except Exception as e:\n", + " print(f\"wrong audience refused ({type(e).__name__}):\", e)" + ] + }, + { + "cell_type": "markdown", + "id": "a08ef8f0", + "metadata": {}, + "source": [ + "Trust is **minted into the credential at issuance** — `trust_level` is a claim. So\n", + "promotion never upgrades outstanding tokens: the agent re-issues to benefit. The corollary\n", + "is the one that matters for security: *demotion doesn't downgrade them either* — a demoted\n", + "identity's already-issued credentials keep their elevated claim until expiry, which is why a\n", + "trust downgrade must travel with revocation (§5's compromise signal), not just a record\n", + "update. Here the identity gets promoted (via the admin surface for brevity — the *attested*\n", + "promotion path, ODIS-L1-03/11, is demonstrated in the raw companion), and a **fresh**\n", + "credential now clears the target's trust gate:" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "6e1b56cd", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-04T20:42:25.292833Z", + "iopub.status.busy": "2026-09-04T20:42:25.292768Z", + "iopub.status.idle": "2026-09-04T20:42:25.302926Z", + "shell.execute_reply": "2026-09-04T20:42:25.302496Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "trust gate cleared: first_party\n" + ] + } + ], + "source": [ + "client.identities.update(orch.id, trust_level=\"first_party\")\n", + "token = client.tokens.issue_api_key(reg.api_key, scope=\"data:read data:write\")\n", + "identity = client.tokens.verify(token.access_token)\n", + "identity.require_trust(\"first_party\")\n", + "print(\"trust gate cleared:\", identity.trust_level)" + ] + }, + { + "cell_type": "markdown", + "id": "6f76f7fc", + "metadata": {}, + "source": [ + "## 4 · Delegation Record — Layer 2 · The Bridge (ODIS §6.3, Pillar 4, L2-05/06)\n", + "\n", + "The researcher registers with its own holder key and a **credential policy** whose\n", + "`allowed_scopes` is just `data:read` — that policy is the delegation ceiling. It proves\n", + "possession of the key in the `actor_token` (a self-signed ES256 assertion — the SDK doesn't\n", + "mint these; five lines of PyJWT do), and the orchestrator delegates via RFC 8693.\n", + "Attenuation is **monotonic and visible**: requesting `data:read data:write` yields a token\n", + "narrowed to `data:read` — never more than the ceiling — and a request where *nothing*\n", + "survives the intersection is refused outright." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "5e769b33", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-04T20:42:25.303920Z", + "iopub.status.busy": "2026-09-04T20:42:25.303847Z", + "iopub.status.idle": "2026-09-04T20:42:25.320697Z", + "shell.execute_reply": "2026-09-04T20:42:25.320280Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "granted scope (requested read+write): 'data:read'\n", + "sub: researcher-059ce3 | is_delegated: True | act: {'sub': 'spiffe://highflame.ai/acct-sdk-demo/proj-sdk-demo/agent/orch-059ce3'} | depth: 1\n", + "empty intersection refused (APIError): [400] invalid_scope: requested scopes are not available for delegation\n" + ] + } + ], + "source": [ + "ceiling = client.credential_policies.create(\n", + " name=f\"researcher-ceiling-{run}\", allowed_scopes=[\"data:read\"],\n", + " allowed_grant_types=[\"api_key\", \"token_exchange\"], max_delegation_depth=2)\n", + "\n", + "priv = ec.generate_private_key(ec.SECP256R1())\n", + "pub_pem = priv.public_key().public_bytes(serialization.Encoding.PEM,\n", + " serialization.PublicFormat.SubjectPublicKeyInfo).decode()\n", + "researcher = client.agents.register(\n", + " name=\"Researcher\", external_id=f\"researcher-{run}\", sub_type=\"tool_agent\",\n", + " created_by=\"demo-admin@example.com\", trust_level=\"first_party\",\n", + " public_key_pem=pub_pem, credential_policy_id=ceiling.id).identity\n", + "\n", + "now = int(time.time())\n", + "actor_token = jwt.encode({\"iss\": researcher.wimse_uri, \"sub\": researcher.wimse_uri,\n", + " \"aud\": [\"http://localhost:8899\"], \"iat\": now, \"exp\": now + 300},\n", + " priv, algorithm=\"ES256\")\n", + "\n", + "delegated = client.tokens.issue_token_exchange(\n", + " subject_token=token.access_token, actor_token=actor_token, scope=\"data:read data:write\")\n", + "print(\"granted scope (requested read+write):\", repr(delegated.scope))\n", + "\n", + "d_identity = client.tokens.verify(delegated.access_token)\n", + "print(\"sub:\", d_identity.sub.split(\"/\")[-1], \"| is_delegated:\", d_identity.is_delegated(),\n", + " \"| act:\", d_identity.act, \"| depth:\", d_identity.delegation_depth)\n", + "\n", + "try:\n", + " client.tokens.issue_token_exchange(subject_token=token.access_token,\n", + " actor_token=actor_token, scope=\"data:write admin:all\")\n", + "except Exception as e:\n", + " print(f\"empty intersection refused ({type(e).__name__}):\", e)" + ] + }, + { + "cell_type": "markdown", + "id": "a42a9ba3", + "metadata": {}, + "source": [ + "## 5 · Compromise signal, cascade — and what native mode must add — L1-12, L3-04/05\n", + "\n", + "A `critical` CAE signal against the orchestrator kills its credentials and cascades to the\n", + "researcher's delegated token. Then the ODIS-relevant subtlety: **local JWKS verification\n", + "cannot see revocation** — the delegated token still verifies offline, while introspection\n", + "reports it dead. ODIS's definition of an ODIS-aware target requires enforcing *revocation\n", + "semantics*, not just signatures: a native-mode target pairs local verification with\n", + "introspection, short cache windows, or the revocation event stream (`GET /signals/stream`).\n", + "This is exactly the trade the role-capability statement flags under L2-11/L3-04." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "79b3f362", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-04T20:42:25.321770Z", + "iopub.status.busy": "2026-09-04T20:42:25.321685Z", + "iopub.status.idle": "2026-09-04T20:42:25.328331Z", + "shell.execute_reply": "2026-09-04T20:42:25.327943Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "introspection: False\n", + "local JWKS verify alone: True ← why native-mode targets must also check revocation state\n" + ] + } + ], + "source": [ + "client.signals.ingest(signal_type=\"anomalous_behavior\", source=\"sdk-demo\",\n", + " identity_id=orch.id, severity=\"critical\",\n", + " payload={\"reason\": \"prompt injection detected\"})\n", + "\n", + "print(\"introspection:\", client.tokens.introspect(delegated.access_token).active)\n", + "still_verifies = bool(client.tokens.verify(delegated.access_token).sub)\n", + "print(\"local JWKS verify alone:\", still_verifies, \"← why native-mode targets must also check revocation state\")" + ] + }, + { + "cell_type": "markdown", + "id": "3c3559ba", + "metadata": {}, + "source": [ + "## What the SDK view added\n", + "\n", + "| ODIS concept | Where |\n", + "|---|---|\n", + "| Native-mode target validation (§2.5, L2-15) | §3 — local JWKS verify + typed guards |\n", + "| Audience enforcement (§2.5 duty list) | §3 — `verify(audience=...)` refusing a mis-audienced token |\n", + "| Target-side trust gating (complement to issuance gating) | §3 — `require_trust` refusing a valid token |\n", + "| Monotonic attenuation, both modes (L2-06) | §4 — silent narrowing + empty-intersection refusal |\n", + "| Revocation semantics are part of native mode (L3-04, L2-11) | §5 — verify-vs-introspect divergence |\n", + "\n", + "**SDK gaps observed (0.3.23), tracked in sdk#105:** no attestation\n", + "submit/verify, no delegation-graph reads, and no DPoP proof support on token\n", + "issuance — now load-bearing,\n", + "since the server can refuse proof-less issuance deployment-wide (`token.require_dpop`,\n", + "zeroid#304) — nor resource-server-side holder-binding (`cnf.jkt`/DPoP-proof) verification,\n", + "so a Python native-mode target can enforce signature, expiry, scope, trust, delegation,\n", + "audience, and (via introspection) revocation, but not yet holder binding. Everything above that the SDK lacks is\n", + "demonstrated over raw HTTP in the [main walkthrough](./odis-walkthrough.ipynb)." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/odis/odis-walkthrough.ipynb b/examples/odis/odis-walkthrough.ipynb new file mode 100644 index 00000000..1d05370e --- /dev/null +++ b/examples/odis/odis-walkthrough.ipynb @@ -0,0 +1,873 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "a93c50eb", + "metadata": {}, + "source": [ + "# ZeroID as ODIS Layers 1–2 — an executable walkthrough\n", + "\n", + "This notebook demonstrates [ZeroID](https://github.com/highflame-ai/zeroid) using the\n", + "vocabulary of the [ODIS draft](https://github.com/cosai-oasis/ws4-odis/blob/main/RFCs/ODIS.md)\n", + "(Open Delegation & Identity Standard, CoSAI/OASIS WS4). Each section is an ODIS concept;\n", + "each code cell is the ZeroID call that implements it. The committed outputs are from a real\n", + "run against a local instance.\n", + "\n", + "It is the executable companion to the\n", + "[role-capability statement](../../docs/odis/role-capability-statement.md), which maps every\n", + "ODIS requirement to code and tests — including the ones ZeroID does **not** meet.\n", + "\n", + "**Prerequisites** (from the repo root):\n", + "\n", + "```bash\n", + "make setup-keys # ES256 + RSA signing keys into ./keys\n", + "docker compose up -d # zeroid + postgres on localhost:8899\n", + "pip install requests pyjwt cryptography\n", + "```\n", + "\n", + "> **Local dev trust model** — this compose deployment runs the admin plane (identities,\n", + "> agents, policies, signals — served at the server root, zeroid#318)\n", + "> *unauthenticated*: tenancy comes from client-supplied `X-Account-ID` / `X-Project-ID`\n", + "> headers, and the CIBA approval in §7 names its subject in the request body. That is a\n", + "> deliberate dev-mode convenience so one notebook can play both the agent and the\n", + "> administrator. In production the admin plane sits behind an authenticated session, and\n", + "> tenancy and approving subject are derived from verified claims — never from the caller's\n", + "> say-so. The committed outputs were generated against the stock compose configuration,\n", + "> including `token.require_dpop: false` (see §5).\n", + "\n", + "| ODIS term | In this notebook |\n", + "|---|---|\n", + "| Agent Registration Record (§6.1) | a ZeroID identity + its `CredentialPolicy` |\n", + "| Agent Runtime Credential (§6.2) | a short-lived DPoP-bound access token |\n", + "| Delegation Record (§6.3) | an RFC 8693 exchange: `act` chain, `delegation_depth`, `mission_id` |\n", + "| Attestation gate (L1-03/11) | trust level raised by a verified attestation, required by policy |\n", + "| Revocation / kill switch (L1-12, L3-04/05) | a CAE signal cascading down the delegation tree |\n", + "| Audit lineage (CC-01/02) | the delegation explorer's per-JTI graph |" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "bd90f802", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-04T20:42:23.873460Z", + "iopub.status.busy": "2026-09-04T20:42:23.873323Z", + "iopub.status.idle": "2026-09-04T20:42:23.877178Z", + "shell.execute_reply": "2026-09-04T20:42:23.876596Z" + } + }, + "outputs": [], + "source": [ + "# Uncomment the next line to install the prerequisites into this kernel, then restart the\n", + "# kernel before running the next cell.\n", + "# %pip install --upgrade requests \"PyJWT[crypto]>=2.8\" cryptography" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "3e8afe30", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-04T20:42:23.878597Z", + "iopub.status.busy": "2026-09-04T20:42:23.878499Z", + "iopub.status.idle": "2026-09-04T20:42:23.939201Z", + "shell.execute_reply": "2026-09-04T20:42:23.938690Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "zeroid: healthy | run id: ba88ca\n" + ] + } + ], + "source": [ + "import base64, json, time, uuid, requests, jwt\n", + "from cryptography.hazmat.primitives.asymmetric import ec\n", + "from cryptography.hazmat.primitives import serialization\n", + "\n", + "BASE = \"http://localhost:8899\"\n", + "# ODIS trust_domain scoping: every control-plane call is tenant-scoped.\n", + "TENANT = {\"X-Account-ID\": \"acct-demo\", \"X-Project-ID\": \"proj-demo\", \"X-User-ID\": \"demo-admin@example.com\"}\n", + "\n", + "def api(method, path, **kw):\n", + " return requests.request(method, BASE + path, headers={**TENANT, **kw.pop(\"headers\", {})}, **kw)\n", + "\n", + "def b64u(b): return base64.urlsafe_b64encode(b).rstrip(b\"=\").decode()\n", + "\n", + "def gen_key():\n", + " \"\"\"An agent's holder key (ODIS: proof-of-possession key). Private half never leaves the agent.\"\"\"\n", + " k = ec.generate_private_key(ec.SECP256R1())\n", + " pem = k.public_key().public_bytes(serialization.Encoding.PEM,\n", + " serialization.PublicFormat.SubjectPublicKeyInfo).decode()\n", + " n = k.public_key().public_numbers()\n", + " jwk = {\"kty\": \"EC\", \"crv\": \"P-256\", \"x\": b64u(n.x.to_bytes(32, \"big\")), \"y\": b64u(n.y.to_bytes(32, \"big\"))}\n", + " return k, pem, jwk\n", + "\n", + "# Display-only decode: no signature check. Real targets verify against the JWKS\n", + "# (the SDK companion's §3 shows that path) — never trust unverified claims.\n", + "def claims_of(tok): return jwt.decode(tok, options={\"verify_signature\": False})\n", + "def show(obj): print(json.dumps(obj, indent=2, default=str))\n", + "\n", + "run = uuid.uuid4().hex[:6] # unique names per run, so the notebook is re-runnable\n", + "print(\"zeroid:\", requests.get(BASE + \"/health\").json()[\"status\"], \"| run id:\", run)" + ] + }, + { + "cell_type": "markdown", + "id": "535625b0", + "metadata": {}, + "source": [ + "## 1 · Agent Registration Record — Layer 1 · The Passport (ODIS §6.1)\n", + "\n", + "ODIS's durable governance record for a logical agent (`agent_id`, lifecycle state, sponsor,\n", + "trust domain) is a ZeroID **identity**. Registration alone confers no authority: the agent is\n", + "born `unverified`, and its stable name is a SPIFFE/WIMSE URI\n", + "(`spiffe://{trust_domain}/{account}/{project}/{type}/{external_id}`) — ODIS's stable\n", + "`agent_id`, distinct from any runtime credential." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "17ac8435", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-04T20:42:23.940405Z", + "iopub.status.busy": "2026-09-04T20:42:23.940314Z", + "iopub.status.idle": "2026-09-04T20:42:23.956421Z", + "shell.execute_reply": "2026-09-04T20:42:23.955909Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{\n", + " \"wimse_uri\": \"spiffe://highflame.ai/acct-demo/proj-demo/agent/orch-ba88ca\",\n", + " \"identity_type\": \"agent\",\n", + " \"trust_level\": \"unverified\",\n", + " \"status\": \"active\",\n", + " \"owner_user_id\": \"demo-admin@example.com\"\n", + "}\n" + ] + } + ], + "source": [ + "orch_key, orch_pem, orch_jwk = gen_key()\n", + "r = api(\"POST\", \"/agents/register\", json={\n", + " \"name\": \"Orchestrator\", \"external_id\": f\"orch-{run}\",\n", + " \"identity_type\": \"agent\", \"sub_type\": \"orchestrator\",\n", + " \"created_by\": \"demo-admin@example.com\", # ODIS L1-10: accountable sponsor\n", + " \"public_key_pem\": orch_pem, # holder public key (L1-09)\n", + "})\n", + "orch = r.json()[\"identity\"]; orch_id = orch[\"id\"]; orch_apikey = r.json()[\"api_key\"]\n", + "show({k: orch[k] for k in (\"wimse_uri\", \"identity_type\", \"trust_level\", \"status\", \"owner_user_id\")})" + ] + }, + { + "cell_type": "markdown", + "id": "53fa9282", + "metadata": {}, + "source": [ + "## 2 · Registration-record governance fields — Layer 1 · The Passport (ODIS §6.1)\n", + "\n", + "ODIS puts `permitted_delegation_modes`, lifetime bounds, and required assurance on the\n", + "registration record. ZeroID models these as a **CredentialPolicy** attached to the identity:\n", + "maximum TTL, permitted grant types, a scope ceiling, a **required trust level**, and a\n", + "**maximum delegation depth**. Note `client_credentials` stays in the allow-list — the\n", + "post-attestation bootstrap credential is itself policy-checked (everything fails closed,\n", + "including the server's own convenience issuance)." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "0eea94fd", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-04T20:42:23.957494Z", + "iopub.status.busy": "2026-09-04T20:42:23.957419Z", + "iopub.status.idle": "2026-09-04T20:42:23.968969Z", + "shell.execute_reply": "2026-09-04T20:42:23.968504Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{\n", + " \"name\": \"odis-demo-ba88ca\",\n", + " \"max_ttl_seconds\": 3600,\n", + " \"required_trust_level\": \"first_party\",\n", + " \"max_delegation_depth\": 2\n", + "}\n" + ] + } + ], + "source": [ + "r = api(\"POST\", \"/credential-policies\", json={\n", + " \"name\": f\"odis-demo-{run}\",\n", + " \"max_ttl_seconds\": 3600, # L1-05: bounded lifetime\n", + " \"allowed_grant_types\": [\"api_key\", \"token_exchange\", \"client_credentials\"],\n", + " \"allowed_scopes\": [\"data:read\", \"data:write\"], # scope ceiling\n", + " \"required_trust_level\": \"first_party\", # L1-11: attestation-gated authority\n", + " \"max_delegation_depth\": 2, # Pillar 4: bounded cascade\n", + "})\n", + "policy = r.json()\n", + "api(\"PATCH\", f\"/identities/{orch_id}\", json={\"credential_policy_id\": policy[\"id\"]})\n", + "show({k: policy[k] for k in (\"name\", \"max_ttl_seconds\", \"required_trust_level\", \"max_delegation_depth\")})" + ] + }, + { + "cell_type": "markdown", + "id": "5b08f6ea", + "metadata": {}, + "source": [ + "## 3 · No registration resolution, no authority — The Bridge refuses (ODIS-L2-14 + L1-11)\n", + "\n", + "ODIS: an Agent Runtime Credential is issued only after resolving to an **active** registration\n", + "that permits the request. The identity exists and is `active` — but its policy demands\n", + "`first_party` trust and the agent has never been attested. Issuance must refuse:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "217bd311", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-04T20:42:23.970168Z", + "iopub.status.busy": "2026-09-04T20:42:23.970081Z", + "iopub.status.idle": "2026-09-04T20:42:23.976148Z", + "shell.execute_reply": "2026-09-04T20:42:23.975681Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "400\n", + "{\n", + " \"error\": \"policy_violation\",\n", + " \"error_description\": \"credential policy violation: identity trust level \\\"unverified\\\" does not meet required level \\\"first_party\\\"\"\n", + "}\n" + ] + } + ], + "source": [ + "r = requests.post(BASE + \"/oauth2/token\", json={\"grant_type\": \"api_key\", \"api_key\": orch_apikey, \"scope\": \"data:read\"})\n", + "print(r.status_code); show(r.json())\n", + "assert r.status_code == 400" + ] + }, + { + "cell_type": "markdown", + "id": "7d8b87d1", + "metadata": {}, + "source": [ + "## 4 · Attestation raises trust — Layer 1 · The Passport (ODIS-L1-03 / L1-11)\n", + "\n", + "ODIS Layer 1 gates credentials on attestation. ZeroID's production verifier is a generic,\n", + "fail-closed **OIDC workload attestation** (GitHub Actions, GCP Workload Identity Federation,\n", + "Kubernetes projected SA tokens — configured per tenant via `AttestationPolicy`; see\n", + "`docs/attestation.md`). A local notebook has no cloud control plane, so this run uses the\n", + "**dev-stub** proof type (`image_hash`) that ships for exactly this purpose — the flow,\n", + "promotion semantics, and policy gates are identical. Verification promotes trust\n", + "exactly-once inside a transaction: `hardware → first_party`." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "20b177cc", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-04T20:42:23.977223Z", + "iopub.status.busy": "2026-09-04T20:42:23.977148Z", + "iopub.status.idle": "2026-09-04T20:42:23.990895Z", + "shell.execute_reply": "2026-09-04T20:42:23.990414Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "verify: 200\n", + "trust level now: first_party\n" + ] + } + ], + "source": [ + "att = api(\"POST\", \"/attestation/submit\", json={\n", + " \"identity_id\": orch_id, \"level\": \"hardware\",\n", + " \"proof_type\": \"image_hash\", \"proof_value\": \"sha256:deadbeef\", # dev stub: local demo only\n", + "}).json()\n", + "r = api(\"POST\", \"/attestation/verify\", json={\"attestation_id\": att[\"id\"]})\n", + "print(\"verify:\", r.status_code)\n", + "print(\"trust level now:\", api(\"GET\", f\"/identities/{orch_id}\").json()[\"trust_level\"])" + ] + }, + { + "cell_type": "markdown", + "id": "52d1d7a1", + "metadata": {}, + "source": [ + "## 5 · Agent Runtime Credential — the Passport's output (ODIS §6.2, L1-05 / L1-09)\n", + "\n", + "Short-lived, holder-bound, issued only post-attestation. The **DPoP proof** (RFC 9449) is\n", + "signed by the agent's holder key; the issued token carries `cnf.jkt` (the key thumbprint),\n", + "making it proof-of-possession rather than bearer. The `sub` is the WIMSE URI — logical agent\n", + "and runtime credential are distinct objects, which is ODIS's §1.3 identifier/credential\n", + "separation.\n", + "\n", + "Deployment note: the Bearer *fallback* (omit the header, get an unbound token) can be\n", + "closed deployment-wide with `token.require_dpop: true` in zeroid.yaml — issuance without a\n", + "proof is then refused with `invalid_dpop_proof` and the AS metadata advertises\n", + "`dpop_bound_access_tokens_required` (RFC 9449 §5.1). This notebook's compose config keeps\n", + "the default (off) so the Bearer comparison in §6's delegation flow stays visible." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "b6d02473", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-04T20:42:23.992007Z", + "iopub.status.busy": "2026-09-04T20:42:23.991931Z", + "iopub.status.idle": "2026-09-04T20:42:24.003875Z", + "shell.execute_reply": "2026-09-04T20:42:24.003432Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "token_type: DPoP | expires_in: 3600 s\n", + "{\n", + " \"sub\": \"spiffe://highflame.ai/acct-demo/proj-demo/agent/orch-ba88ca\",\n", + " \"trust_level\": \"first_party\",\n", + " \"mission_id\": \"deddd91a-cdee-4d14-8f37-7e2130698154\",\n", + " \"jti\": \"deddd91a-cdee-4d14-8f37-7e2130698154\"\n", + "}\n", + "cnf: {'jkt': 'j-q4OkwXHy0F2KAwkbBpSjWX7I0JX2MYRljci5AhTmg'}\n" + ] + } + ], + "source": [ + "def dpop_proof(key, jwk, htm, htu):\n", + " return jwt.encode({\"jti\": str(uuid.uuid4()), \"htm\": htm, \"htu\": htu, \"iat\": int(time.time())},\n", + " key, algorithm=\"ES256\", headers={\"typ\": \"dpop+jwt\", \"jwk\": jwk})\n", + "\n", + "proof = dpop_proof(orch_key, orch_jwk, \"POST\", f\"{BASE}/oauth2/token\")\n", + "r = requests.post(BASE + \"/oauth2/token\", headers={\"DPoP\": proof},\n", + " json={\"grant_type\": \"api_key\", \"api_key\": orch_apikey, \"scope\": \"data:read data:write\"})\n", + "tok = r.json(); orch_token = tok[\"access_token\"]\n", + "c = claims_of(orch_token)\n", + "print(\"token_type:\", tok[\"token_type\"], \"| expires_in:\", tok[\"expires_in\"], \"s\")\n", + "show({k: c[k] for k in (\"sub\", \"trust_level\", \"mission_id\", \"jti\")}); print(\"cnf:\", c[\"cnf\"])" + ] + }, + { + "cell_type": "markdown", + "id": "4bd09e14", + "metadata": {}, + "source": [ + "A replayed proof must be rejected — the `jti` ledger is atomic (ODIS's replay concern\n", + "under L1-09; ZeroID's `TestDPoPReplayRejected` class of behavior, live):" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "9d11293e", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-04T20:42:24.004950Z", + "iopub.status.busy": "2026-09-04T20:42:24.004866Z", + "iopub.status.idle": "2026-09-04T20:42:24.009376Z", + "shell.execute_reply": "2026-09-04T20:42:24.008960Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "400\n", + "{\n", + " \"error\": \"invalid_dpop_proof\",\n", + " \"error_description\": \"dpop: proof jti has already been observed within the freshness window (dpop_replay_detected)\"\n", + "}\n" + ] + } + ], + "source": [ + "r = requests.post(BASE + \"/oauth2/token\", headers={\"DPoP\": proof},\n", + " json={\"grant_type\": \"api_key\", \"api_key\": orch_apikey, \"scope\": \"data:read\"})\n", + "print(r.status_code); show(r.json())\n", + "assert r.status_code == 400" + ] + }, + { + "cell_type": "markdown", + "id": "a91ec435", + "metadata": {}, + "source": [ + "## 6 · Delegation Record — Layer 2 · The Bridge (ODIS §6.3, Pillar 4, L2-01/05/06)\n", + "\n", + "The orchestrator delegates to a sub-agent via **RFC 8693 token exchange**. The sub-agent\n", + "proves possession of its own holder key (`actor_token`), and the granted scope is the\n", + "**three-way intersection**: requested ∩ what the orchestrator holds ∩ the sub-agent's\n", + "**policy ceiling** — here a second `CredentialPolicy` whose `allowed_scopes` is just\n", + "`data:read`. The result carries ODIS Delegation-Record semantics as claims: `act` (the\n", + "delegating principal), `delegation_depth` (monotonic, capped by policy), and `mission_id`\n", + "(chain lineage, inherited from the root credential). Child expiry is clamped to the\n", + "parent's — a child cannot outlive its parent." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "136badea", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-04T20:42:24.010522Z", + "iopub.status.busy": "2026-09-04T20:42:24.010445Z", + "iopub.status.idle": "2026-09-04T20:42:24.033710Z", + "shell.execute_reply": "2026-09-04T20:42:24.033347Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{\n", + " \"sub\": \"spiffe://highflame.ai/acct-demo/proj-demo/agent/researcher-ba88ca\",\n", + " \"act\": {\n", + " \"sub\": \"spiffe://highflame.ai/acct-demo/proj-demo/agent/orch-ba88ca\"\n", + " },\n", + " \"delegation_depth\": 1,\n", + " \"mission_id\": \"deddd91a-cdee-4d14-8f37-7e2130698154\",\n", + " \"jti\": \"2f8712b1-a479-48c7-9b69-3cc3e3d1946d\"\n", + "}\n" + ] + } + ], + "source": [ + "researcher_policy = api(\"POST\", \"/credential-policies\", json={\n", + " \"name\": f\"odis-demo-researcher-{run}\", \"max_ttl_seconds\": 3600,\n", + " \"allowed_grant_types\": [\"api_key\", \"token_exchange\", \"client_credentials\"],\n", + " \"allowed_scopes\": [\"data:read\"], # the delegation ceiling\n", + " \"required_trust_level\": \"first_party\", \"max_delegation_depth\": 2,\n", + "}).json()\n", + "\n", + "sub_key, sub_pem, _ = gen_key()\n", + "r = api(\"POST\", \"/agents/register\", json={\n", + " \"name\": \"Researcher\", \"external_id\": f\"researcher-{run}\", \"identity_type\": \"agent\",\n", + " \"sub_type\": \"tool_agent\", \"created_by\": \"demo-admin@example.com\",\n", + " \"public_key_pem\": sub_pem, \"credential_policy_id\": researcher_policy[\"id\"],\n", + "})\n", + "researcher = r.json()[\"identity\"]\n", + "att2 = api(\"POST\", \"/attestation/submit\", json={\"identity_id\": researcher[\"id\"], \"level\": \"hardware\",\n", + " \"proof_type\": \"image_hash\", \"proof_value\": \"sha256:cafef00d\"}).json()\n", + "api(\"POST\", \"/attestation/verify\", json={\"attestation_id\": att2[\"id\"]})\n", + "\n", + "actor_token = jwt.encode({\"iss\": researcher[\"wimse_uri\"], \"sub\": researcher[\"wimse_uri\"], \"aud\": BASE,\n", + " \"iat\": int(time.time()), \"exp\": int(time.time()) + 300}, sub_key, algorithm=\"ES256\")\n", + "r = requests.post(BASE + \"/oauth2/token\", json={\n", + " \"grant_type\": \"urn:ietf:params:oauth:grant-type:token-exchange\",\n", + " \"subject_token\": orch_token, \"actor_token\": actor_token, \"scope\": \"data:read\"})\n", + "delegated = r.json()[\"access_token\"]; dc = claims_of(delegated)\n", + "show({k: dc[k] for k in (\"sub\", \"act\", \"delegation_depth\", \"mission_id\", \"jti\")})" + ] + }, + { + "cell_type": "markdown", + "id": "e6a68996", + "metadata": {}, + "source": [ + "Attenuation is **monotonic**: the researcher's policy ceiling is `data:read`, so a\n", + "delegation that tries to smuggle `data:write` (which the *orchestrator* holds) is narrowed\n", + "to the intersection — the child never receives more than its ceiling allows. And when\n", + "*nothing* survives the intersection, the exchange is refused outright. Either way a\n", + "sub-agent can never become a privilege-amplification point (ODIS-L2-06's core demand):" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "ab96d0d3", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-04T20:42:24.034847Z", + "iopub.status.busy": "2026-09-04T20:42:24.034766Z", + "iopub.status.idle": "2026-09-04T20:42:24.043674Z", + "shell.execute_reply": "2026-09-04T20:42:24.043287Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "requested: data:read data:write → granted: ['data:read']\n", + "400\n", + "{\n", + " \"error\": \"invalid_scope\",\n", + " \"error_description\": \"requested scopes are not available for delegation\"\n", + "}\n" + ] + } + ], + "source": [ + "r = requests.post(BASE + \"/oauth2/token\", json={\n", + " \"grant_type\": \"urn:ietf:params:oauth:grant-type:token-exchange\",\n", + " \"subject_token\": orch_token, \"actor_token\": actor_token, \"scope\": \"data:read data:write\"})\n", + "granted = claims_of(r.json()[\"access_token\"])[\"scopes\"]\n", + "print(\"requested: data:read data:write → granted:\", granted)\n", + "assert granted == [\"data:read\"]\n", + "\n", + "r = requests.post(BASE + \"/oauth2/token\", json={\n", + " \"grant_type\": \"urn:ietf:params:oauth:grant-type:token-exchange\",\n", + " \"subject_token\": orch_token, \"actor_token\": actor_token, \"scope\": \"data:write\"})\n", + "print(r.status_code); show(r.json())\n", + "assert r.status_code == 400" + ] + }, + { + "cell_type": "markdown", + "id": "3715ffae", + "metadata": {}, + "source": [ + "## 7 · Bounded asynchronous human authorization — Layer 2 · The Bridge (ODIS-L2-02, Pillar 1)\n", + "\n", + "ODIS Pillar 1: agents need delegated human authority **without dragging the human into the\n", + "execution loop** — the Browser Trap. The Bridge's answer is CIBA (OpenID Client-Initiated\n", + "Backchannel Authentication): the agent requests authority out-of-band, a human approves\n", + "asynchronously (here via the admin surface; ping/push notifier modes exist), and only then\n", + "does polling yield a token. The `binding_message` shows the human exactly what they are\n", + "approving, and the issued token's `sub` is the **approving human** with the grant recorded\n", + "as `token_exchange: \"ciba\"` — originating-principal attribution, ODIS-CC-02.\n", + "\n", + "> The `/approve` call naming its subject in the request body is the dev-mode admin surface\n", + "> (see the trust-model note at the top); a production approval UI derives the subject from\n", + "> the authenticated session. And if the post-approval poll returns `access_denied` on an\n", + "> *upgraded* checkout, your stored default credential policy predates the CIBA grant —\n", + "> `docker compose down -v` for a fresh volume (see the README)." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "069d9b03", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-04T20:42:24.044864Z", + "iopub.status.busy": "2026-09-04T20:42:24.044786Z", + "iopub.status.idle": "2026-09-04T20:42:24.361724Z", + "shell.execute_reply": "2026-09-04T20:42:24.360784Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "bc-authorize: 200\n", + "{\n", + " \"auth_req_id\": \"JBmzNWs_GBYuG96OzPI_OABbG1tgSfkIrKsGUASgJMk\",\n", + " \"expires_in\": 300,\n", + " \"interval\": 5\n", + "}\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "poll before approval: authorization_pending\n", + "after approval — expires_in: 900 s\n", + "{\n", + " \"sub\": \"user-alice-001\",\n", + " \"token_exchange\": \"ciba\",\n", + " \"scopes\": [\n", + " \"data:read\"\n", + " ]\n", + "}\n" + ] + } + ], + "source": [ + "alice_agent = api(\"POST\", \"/identities\", json={\n", + " \"external_id\": f\"alice-proxy-{run}\", \"owner_user_id\": \"alice@example.com\",\n", + " \"identity_type\": \"agent\", \"sub_type\": \"human_proxy\",\n", + " \"allowed_scopes\": [\"data:read\"], \"trust_level\": \"first_party\"}).json()\n", + "cl = api(\"POST\", \"/oauth/clients\", json={\n", + " \"client_id\": f\"alice-proxy-{run}\", \"name\": \"Alice's proxy agent\", \"confidential\": True,\n", + " \"identity_id\": alice_agent[\"id\"], \"grant_types\": [\"urn:openid:params:grant-type:ciba\"],\n", + " \"scopes\": [\"data:read\"]}).json()\n", + "\n", + "r = requests.post(BASE + \"/oauth2/bc-authorize\", json={\n", + " \"client_id\": cl[\"client\"][\"client_id\"], \"client_secret\": cl[\"client_secret\"],\n", + " \"account_id\": TENANT[\"X-Account-ID\"], \"project_id\": TENANT[\"X-Project-ID\"],\n", + " \"login_hint\": \"alice@example.com\", \"scope\": \"data:read\",\n", + " \"binding_message\": \"Agent requests read access to the quarterly dataset\"})\n", + "auth_req = r.json(); print(\"bc-authorize:\", r.status_code); show(auth_req)\n", + "\n", + "poll = lambda: requests.post(BASE + \"/oauth2/token\", json={\n", + " \"grant_type\": \"urn:openid:params:grant-type:ciba\", \"auth_req_id\": auth_req[\"auth_req_id\"],\n", + " \"client_id\": cl[\"client\"][\"client_id\"], \"client_secret\": cl[\"client_secret\"]})\n", + "print(\"poll before approval:\", poll().json()[\"error\"])\n", + "\n", + "api(\"POST\", f\"/oauth2/bc-authorize/{auth_req['auth_req_id']}/approve\",\n", + " json={\"subject_id\": \"user-alice-001\", \"subject_email\": \"alice@example.com\", \"subject_name\": \"Alice\"})\n", + "ciba_token = poll().json()\n", + "cc2 = claims_of(ciba_token[\"access_token\"])\n", + "print(\"after approval — expires_in:\", ciba_token[\"expires_in\"], \"s\")\n", + "show({k: cc2[k] for k in (\"sub\", \"token_exchange\", \"scopes\")})" + ] + }, + { + "cell_type": "markdown", + "id": "dd539e45", + "metadata": {}, + "source": [ + "## 8 · Confirmed compromise → cascade revocation — ODIS-L1-12, L3-04 / L3-05\n", + "\n", + "ODIS: a confirmed compromise signal must revoke the affected credential and everything\n", + "derived from it. Ingesting a `critical` CAE signal against the **orchestrator** revokes its\n", + "credentials and cascades down the `parent_jti` tree — the researcher's *delegated* token dies\n", + "with its parent, and we time how long the whole thing takes:" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "a6038f03", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-04T20:42:24.363860Z", + "iopub.status.busy": "2026-09-04T20:42:24.363709Z", + "iopub.status.idle": "2026-09-04T20:42:24.374309Z", + "shell.execute_reply": "2026-09-04T20:42:24.373839Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "delegated token after cascade: {'active': False} (8 ms signal→dead)\n" + ] + } + ], + "source": [ + "t0 = time.time()\n", + "api(\"POST\", \"/signals/ingest\", json={\n", + " \"identity_id\": orch_id, \"signal_type\": \"anomalous_behavior\", \"severity\": \"critical\",\n", + " \"source\": \"notebook-demo\", \"payload\": {\"reason\": \"prompt injection detected\"}})\n", + "child_state = requests.post(BASE + \"/oauth2/token/introspect\", json={\"token\": delegated}).json()\n", + "print(f\"delegated token after cascade: {child_state} ({(time.time()-t0)*1000:.0f} ms signal→dead)\")\n", + "assert child_state == {\"active\": False}" + ] + }, + { + "cell_type": "markdown", + "id": "c3d92a70", + "metadata": {}, + "source": [ + "## 9 · Audit lineage survives the kill — ODIS-CC-01 / CC-02\n", + "\n", + "Revoked credentials are retained past expiry precisely so the delegation graph remains\n", + "walkable for forensics. The per-JTI lineage shows both identities (dual-identity audit),\n", + "the scope attenuation at each hop (`scopes_in` → `scopes_out`), and *why* each edge died:" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "72bd5ca1", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-04T20:42:24.375837Z", + "iopub.status.busy": "2026-09-04T20:42:24.375711Z", + "iopub.status.idle": "2026-09-04T20:42:24.381511Z", + "shell.execute_reply": "2026-09-04T20:42:24.381205Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "chain: orch-ba88ca → researcher-ba88ca\n", + "[\n", + " {\n", + " \"grant_type\": \"api_key\",\n", + " \"delegation_depth\": 0,\n", + " \"scopes_in\": [\n", + " \"data:read\",\n", + " \"data:write\"\n", + " ],\n", + " \"scopes_out\": [\n", + " \"data:read\",\n", + " \"data:write\"\n", + " ],\n", + " \"attenuated\": [],\n", + " \"is_revoked\": true,\n", + " \"revoke_reason\": \"auto-revoked by CAE signal a6978e71-8487-4e8a-b2f2-c633f37ed276 (severity: critical)\"\n", + " },\n", + " {\n", + " \"grant_type\": \"token_exchange\",\n", + " \"delegation_depth\": 1,\n", + " \"scopes_in\": [\n", + " \"data:read\",\n", + " \"data:write\"\n", + " ],\n", + " \"scopes_out\": [\n", + " \"data:read\"\n", + " ],\n", + " \"attenuated\": [\n", + " \"data:write\"\n", + " ],\n", + " \"is_revoked\": true,\n", + " \"revoke_reason\": \"auto-revoked by CAE signal a6978e71-8487-4e8a-b2f2-c633f37ed276 (severity: critical)\"\n", + " }\n", + "]\n" + ] + } + ], + "source": [ + "g = api(\"GET\", f\"/delegations/by-jti/{dc['jti']}\").json()\n", + "print(\"chain:\", \" → \".join(n[\"wimse_uri\"].split(\"/\")[-1] for n in g[\"nodes\"]))\n", + "show([{k: e[k] for k in (\"grant_type\", \"delegation_depth\", \"scopes_in\", \"scopes_out\",\n", + " \"attenuated\", \"is_revoked\", \"revoke_reason\")} for e in g[\"edges\"]])" + ] + }, + { + "cell_type": "markdown", + "id": "87498388", + "metadata": {}, + "source": [ + "## 10 · The sponsor leaves — offboarding kill switch (ODIS-L1-06 / L1-10 / L3-05)\n", + "\n", + "ODIS-L1-10: every agent has an accountable human sponsor, and sponsor lifecycle events must\n", + "trigger de-provisioning. Alice — the human who just approved her proxy agent's authority in\n", + "§7 — leaves the organization. One call deactivates every identity she owned and\n", + "cascade-revokes their credentials, including the CIBA-approved token: the kill switch\n", + "(L3-05) keyed on the human, not the agent.\n", + "\n", + "Reading the response correctly: `credentials_revoked` is **not** the cascade count. The\n", + "cascade runs *inside* each identity's deactivation; this counter only reports stragglers\n", + "caught by a final safety-net sweep, so `0` is the healthy value. The proof of revocation is\n", + "the credential itself — introspection reports it dead, and the audit graph (§9) records the\n", + "edge as revoked with the offboarding reason:" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "0dbc20ec", + "metadata": { + "execution": { + "iopub.execute_input": "2026-09-04T20:42:24.383167Z", + "iopub.status.busy": "2026-09-04T20:42:24.383066Z", + "iopub.status.idle": "2026-09-04T20:42:24.398210Z", + "shell.execute_reply": "2026-09-04T20:42:24.397666Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{\n", + " \"$schema\": \"http://localhost:8899/admin/schemas/OffboardByOwnerOutputBody.json\",\n", + " \"identities_deactivated\": 1,\n", + " \"credentials_revoked\": 0\n", + "}\n", + "alice's CIBA-approved token: {'active': False}\n", + "[\n", + " {\n", + " \"grant_type\": \"urn:openid:params:grant-type:ciba\",\n", + " \"is_revoked\": true,\n", + " \"revoke_reason\": \"identity_deactivated\"\n", + " }\n", + "]\n" + ] + } + ], + "source": [ + "r = api(\"POST\", \"/identities/offboard-by-owner\", json={\"owner_user_id\": \"alice@example.com\"})\n", + "show(r.json())\n", + "dead = requests.post(BASE + \"/oauth2/token/introspect\", json={\"token\": ciba_token[\"access_token\"]}).json()\n", + "print(\"alice's CIBA-approved token:\", dead)\n", + "assert dead == {\"active\": False}\n", + "\n", + "g = api(\"GET\", f\"/delegations/by-jti/{cc2['jti']}\").json()\n", + "show([{k: e[k] for k in (\"grant_type\", \"is_revoked\", \"revoke_reason\")} for e in g[\"edges\"]])\n", + "assert all(e[\"is_revoked\"] for e in g[\"edges\"])" + ] + }, + { + "cell_type": "markdown", + "id": "44b3c44c", + "metadata": {}, + "source": [ + "## What you just saw, in ODIS terms\n", + "\n", + "| ODIS concept | Layer | Demonstrated |\n", + "|---|---|---|\n", + "| §6.1 Agent Registration Record; §1.3 identifier ≠ credential; CC-05 governed creation; L1-10 accountable sponsor | The Passport | §1 — registered identity, WIMSE `agent_id`, no authority conferred |\n", + "| §6.1 governance fields: lifetime bound, permitted grants, required trust, delegation-depth cap | The Passport | §2 — `CredentialPolicy` attached to the registration record |\n", + "| L2-14 registration resolution before authority; L1-11 fail closed | The Bridge | §3 — issuance refused for an unattested identity |\n", + "| L1-03 / L1-11 attestation-bootstrapped trust | The Passport | §4 — verification promoted trust, atomically |\n", + "| §6.2 Agent Runtime Credential; L1-05 / L1-09 proof-of-possession | The Passport | §5 — DPoP-bound token, replay rejected (`token.require_dpop` makes binding mandatory deployment-wide) |\n", + "| §6.3 Delegation Record; Pillar 4; L2-01 / L2-05 / L2-06 monotonic attenuation | The Bridge | §6 — `act` chain, depth; out-of-ceiling scope narrowed, empty intersection refused |\n", + "| L2-02 bounded async authorization; Pillar 1 delegated principal identity | The Bridge | §7 — CIBA approval with binding message; token `sub` = the human |\n", + "| L1-12 / L3-04 / L3-05 compromise signal, cascade | (Router-adjacent) | §8 — critical signal killed the whole tree in milliseconds |\n", + "| CC-01 / CC-02 dual-identity audit lineage | cross-cutting | §9 — the graph outlives the credentials |\n", + "| L1-06 / L1-10 / L3-05 sponsor offboarding, kill switch | The Passport | §10 — the human leaves; every identity and credential they sponsored dies |\n", + "\n", + "**What ZeroID deliberately does *not* demonstrate** — software/supply-chain attestation\n", + "(L1-02/08), bridge-mode provider adapters (L2-08..10), presenter isolation (L2-13), and\n", + "velocity limits (L3-03) are open gaps, documented with the same candor in the\n", + "[role-capability statement](../../docs/odis/role-capability-statement.md). That document is\n", + "the map; this notebook is the territory." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/zeroid_quickstart.ipynb b/examples/zeroid_quickstart.ipynb index 16067274..c0ed3e0a 100644 --- a/examples/zeroid_quickstart.ipynb +++ b/examples/zeroid_quickstart.ipynb @@ -80,7 +80,9 @@ "metadata": {}, "outputs": [], "source": [ - "#!pip install -q highflame cryptography PyJWT" + "# Uncomment the next line to install the prerequisites into this kernel (latest SDK\n", + "# release), then restart the kernel before running the next cell.\n", + "# %pip install --upgrade highflame \"PyJWT[crypto]>=2.13\" cryptography" ] }, {