From 24c1f56f00e55468a58d89611b160dd6469ed667 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 11 Oct 2026 19:06:01 +0200 Subject: [PATCH] Add proposed task execution policy and initiating identity contract Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a126e1-76f8-7643-8cf3-e93336539ade --- ...26-10-11-task-execution-provider-source.md | 86 +++++++++ ...-execution-initiating-provider-contract.md | 97 ++++++++++ .../policy_fixtures.yaml | 16 ++ .../policy_package.md | 113 ++++++++++++ internal/policy/task_execution_test.go | 104 +++++++++++ internal/taskexecution/identity.go | 168 ++++++++++++++++++ internal/taskexecution/identity_test.go | 61 +++++++ tools/task_execution_interop_fixture.py | 13 ++ 8 files changed, 658 insertions(+) create mode 100644 docs/reviews/2026-10-11-task-execution-provider-source.md create mode 100644 docs/task-execution-initiating-provider-contract.md create mode 100644 examples/secrets-engine-task-execution/policy_fixtures.yaml create mode 100644 examples/secrets-engine-task-execution/policy_package.md create mode 100644 internal/policy/task_execution_test.go create mode 100644 internal/taskexecution/identity.go create mode 100644 internal/taskexecution/identity_test.go create mode 100644 tools/task_execution_interop_fixture.py diff --git a/docs/reviews/2026-10-11-task-execution-provider-source.md b/docs/reviews/2026-10-11-task-execution-provider-source.md new file mode 100644 index 0000000..388a533 --- /dev/null +++ b/docs/reviews/2026-10-11-task-execution-provider-source.md @@ -0,0 +1,86 @@ +# Access owner source contract and readiness receipt + +reviewer: access-engine owner (agent access_task_execution_policy, Codex) +disposition: bounded source contract acknowledged; executable readiness refused +work_item: FLEX-WP-ADHOC-2026-10-11-T04 + +The completed own source-preparation anchor was persisted before this receipt: +Access commit 1d3a8727f50d4e855e5ddda9f8746d0f2dabbc12, exact +workplans/ADHOC-2026-10-11.md SHA256 +4df32aafda0339a073bc2a512eef8d0db6686617ce6027ed554b3f5c68e4f2c6. +All eight staged hashes were rechecked against immutable source manifest +/tmp/access-execute-ccr-stage/manifest.json SHA256 +e7a36575c1e521613cc6092371058833d7f43b6739c3cee5b0929cb90c708d88. +The source maker and this owner-role reviewer are the same agent: this receipt +is not an independent code review or foreign issuer/provider acceptance. + +## Bounded source agreement + +Access INTENT Responsibility Boundary reserves policy decisions to the sole PDP, +authentication to the identity layer, and protected effects to the consumer. +The separate proposed secrets-engine.task-execution v1 package/action execute-ccr +preserves that boundary: it neither overwrites lifecycle v2 nor translates apply +into scoped task execution. Its literal owner_admission=false keeps the entire +package closed today, including requests asserting verified, human_control or +owner_admission. No consumer boolean can enable it. + +The actual Secrets immutable descriptor and existing canonical full/approval-free +digest joins are the appropriate input for exact operation/CCR/grant/catalogue, +source, ordered target/body plan, expected states, budget and deadline binding. +The candidate policy models one two-object GET/PUT/GET plan, not arbitrary +apply/provision/exec. Independent Platform source/body compiler and native ACL +scope remain required; shape/hash matching never establishes owner-source trust. + +The source-only cryptographic verifier reuses existing KeyCape RS256 human +access-token claim fields out of band. It uses owner-selected issuer/audience/ +public-key pins and trusted server time; rejects service subjects, foreign or +registration-only tenant provenance, missing/stale MFA, expiry/future activation, +wrong issuer/audience/key/signature and ambiguous JSON. It returns opaque identity +only. It is not wired into HTTP evaluation, issues no key/token, selects no real +client, accepts no native issuer and makes no authorization decision. + +## Readiness refusal and owner dependencies + +No authenticated initiating-human channel, admitted User/Tenant PIP retrieval, +transport-to-human representation or server projection exists in this patch. +The proposed task_execution_projection is an internal integration sketch, not an +accepted public identity claim. Raw human JWTs must not enter CheckRequest, +context, audit/decision records or receipts. Server implementation must reject or +discard caller projection fields, independently verify KeyCape identity, acquire +User membership/canonical-user facts and Tenant lifecycle facts through their +actual authenticated interfaces, and impose the earliest freshness/lifetime +ceiling. Service TokenReview proves transport only. Approval's human approver is +not automatically the initiating human; a registration tenant is not membership. + +This receipt does not accept anything on KeyCape, User Engine, Tenant Engine, +Approval Engine, Platform or Secrets Engine's behalf. Actual client/audience, +issuer/key admission, PIP transport and freshness contracts, and representation +must receive their owners' acceptance and integration evidence. Removal of the +hard refusal requires a separately reviewed versioned source successor, never +an unsigned flag, helm value or literal flip in a runtime test. + +Served approval claim validation and exact issue/full/submitted digest joins, +trusted consume confirmation, owner compiler, durable journal and task grant/ +cleanup mediation remain required independently. Existing SECRETS-WP-0013 and +RPF-WP-0035-T12 drive integration; no clone driver or operational closure is +introduced. Native lifecycle adoption and standing service authority stay false. + +## Actual source verification + +Maker command in isolated staged Access source: +GOCACHE=/tmp/access-taskexecution-go-cache go test -json +./internal/taskexecution ./internal/policy ./pkg/api. +Terminal exit zero: 118 passing Go test events, including 24 new parent/subtest +events, zero failures. Log /tmp/access-execute-ccr-stage/test-results.jsonl. +Actual Secrets Python ExecutionContract.parse and canonical request/approval-free +digest functions produce the same complete synthetic descriptor and digests as +Go. OPA candidate allow and mutation denials use an isolated in-memory package +with the hard false literal replaced for the test alone. The actual staged +package remains false and denies all requests. These synthetic results prove +source semantics/interoperability, not provider facts or an available runtime +allow path. The cross-repo generator currently relies on the estate's Secrets +virtualenv, and is not claimed to be a portable standalone fixture. + +No native call, credential retrieval, package activation, foreign owner checkout +write or privileged-action trial occurred. Exact source publication and independent +code verification remain with the coordinator before adoption. diff --git a/docs/task-execution-initiating-provider-contract.md b/docs/task-execution-initiating-provider-contract.md new file mode 100644 index 0000000..66dd47a --- /dev/null +++ b/docs/task-execution-initiating-provider-contract.md @@ -0,0 +1,97 @@ +# Proposed Secrets exact execution policy and initiating-provider boundary + +Prepared for Secrets SECRETS-WP-0013-T01 and Platform RPF-WP-0035-T12. +This is a proposed Access source contract. It adopts no new identity protocol, +issuer/client/key, runtime provider, source standard or native entitlement. + +## Why the separate policy exists + +Lifecycle v2 deliberately serves twelve generic operations. Existing apply +ignores execution_contract and its service subject cannot prove which human +initiated a task. The new package secrets-engine.task-execution v1 recognizes +only execute-ccr and is separate from secrets-engine.catalog-lane.lifecycle v2. +The entire execution descriptor, including operation/CCR/grant/catalogue/source, +ordered two-object read/write/readback plan, budget and deadline, participates in +existing canonical request and approval-free digest joins. No translation to +apply and no service administrative grant occurs. + +The shipped source package is deliberately closed: owner_admission is a literal +false. An unsigned verified, owner_admission or human_control request flag cannot +open it. Synthetic future-provider tests replace that literal in an isolated +in-memory package only; those allows are not runtime admission or native evidence. +This is an enforced refusal until the identity boundary exists, not a switch to +turn on once a human approves a proposal. + +## Reuse the actual providers + +* KeyCape owns authentication/MFA and RS256 access-token issuance. Reuse + docs/approval-engine-auth-contract.md and docs/tenant-claim-contract.md from + KeyCape: exact issuer/audience/subject/principal_type/tenant/tenant_source, + expiry and assurance level/mfa/source/at. internal/taskexecution's proposed + source verifier consumes the token out of band using locally owner-pinned RSA + keys, trusted server time and no JWT-selected discovery URL. It returns an + opaque Initiator, never a token or an authorization decision. No real issuer, + key or client is installed in the source. Human initiating audience/client + admission is still missing; an approval-engine access audience cannot silently + be reused for task execution. +* Access's existing callerauth TokenReview choke point authenticates the service + transport and binds secrets-engine to its exact Kubernetes principal. It does + not authenticate the request's subject human or prove representation. Reuse + that authenticated server identity, never caller transport_principal text. +* User Engine owns canonical user and active tenant account/membership facts; + Tenant Engine owns authoritative tenant existence/lifecycle. Their live, + authenticated, freshness-bounded read interfaces must be integrated by Access + (PIP consumption), with unavailable/stale facts reducing authority. No new + parallel identity engine or caller-authored membership provider is introduced. +* Approval Engine supplies the served approval claim and authenticated consumption + facts. Its human approver can differ from the initiating human: a successful + approval is not initiating identity. Existing claim/PDP canonical correspondence + and later independent consume confirmation remain mandatory. + +KeyCape explicitly states registration-supplied tenant is store isolation, not +human membership. The source verifier therefore requires directory provenance, +exact tenant:platform, verified MFA within 300 seconds and a currently valid +RS256 token. This strict proposed ceiling currently refuses registration-only +humans; changing that requirement requires owner-reviewed doctrine, not relabeling +registration as directory. Cryptographic verification alone still grants nothing: +User/Tenant membership, representation and exact operation are separate facts. + +## Required server injection and activation proof + +The task_execution_projection shape in the candidate policy is a proposed +internal server representation, not an accepted public claim format. Access +must acquire initiator token on a separate authenticated channel, verify it, +resolve canonical user/membership/tenant using the owning PIPs, correlate it with +the authenticated transport and full exact operation, and inject a bounded +projection. Raw human JWTs must never enter CheckRequest context or decision/audit +records. An HTTP-supplied projection must be rejected or discarded before any +trusted projection is injected, including nested/duplicate-field collisions. + +Projection expiry must be the earliest identity/session expiry, provider +freshness deadline, approved operation deadline and PDP authority ceiling. +Fresh PIP retrieval/refusal and revocation visibility must be recorded; cached +membership alone cannot silently extend a KeyCape session. Package activation +requires an actual authenticated KeyCape origin/client, admitted User/Tenant read +routes/identities, server-controlled provenance and exact request/claim binding. +No raw JWT verifier endpoint, projection injection, transport-to-human binding, +PIP resolver, new server route or native package deployment is implemented here. +Only a reviewed versioned source successor may remove the hard refusal after +these gates have executable counterexamples. There is no unsigned enable flag. + +## Tested scope and unfinished integration + +Source tests verify RSA signatures and wrong issuer/audience/principal/tenant, +registration provenance, missing/stale MFA, expired/future token and absent key +admission. Strict nested duplicate/trailing JSON is refused. Python uses the +actual Secrets ExecutionContract parser and canonical request/approval digest +functions; Go and OPA evaluate the same complete synthetic two-object descriptor. +Synthetic future-projection allow and mutation denials prove candidate semantics; +current package denies regardless of asserted flags. They do not prove providers. + +The candidate predicate is not a full independent typed owner compiler: source +and body provenance, exact estate origin/CCR readiness, bounded parser admission, +platform plan compilation and immutable body mediation remain Platform/Secrets +requirements. Nor does it independently validate served approval claims, consume, +run journal admission, dispatch or issue/revoke native grants. Existing journal, +confirmation and descriptor work must be integrated before this can perform one +metadata action. No existing operational task closes from this source package. diff --git a/examples/secrets-engine-task-execution/policy_fixtures.yaml b/examples/secrets-engine-task-execution/policy_fixtures.yaml new file mode 100644 index 0000000..3a7350a --- /dev/null +++ b/examples/secrets-engine-task-execution/policy_fixtures.yaml @@ -0,0 +1,16 @@ +- id: unsigned-provider-refused + request: + tenant: tenant:platform + subject: {id: secrets-engine, type: service} + action: execute-ccr + resource: {id: catalog:test, type: secret-catalog-lane, system: secrets-engine} + context: {human_control: true, verified: true, owner_admission: true} + expect: {effect: deny, reason: initiating_provider_unadmitted} +- id: legacy-action-cannot-borrow-task-policy + request: + tenant: tenant:platform + subject: {id: secrets-engine, type: service} + action: apply + resource: {id: catalog:test, type: secret-catalog-lane, system: secrets-engine} + context: {human_control: true} + expect: {effect: deny, reason: initiating_provider_unadmitted} diff --git a/examples/secrets-engine-task-execution/policy_package.md b/examples/secrets-engine-task-execution/policy_package.md new file mode 100644 index 0000000..81c0818 --- /dev/null +++ b/examples/secrets-engine-task-execution/policy_package.md @@ -0,0 +1,113 @@ +--- +id: secrets-engine.task-execution +name: Secrets exact task execution +namespace: secrets-engine:secret-catalog-lane +version: v1 +status: proposed +package: flexauth.secrets_engine.task_execution +allow_ttl: 15s +actions: [execute-ccr] +owner: team:platform-security +fixtures: [policy_fixtures.yaml] +caring: + profile: caring-0.4.0-rc2 + enforce: false +activation: + mode: local +--- + +# Proposed task execution contract + +Source only. The owner-admission constant is false because no authenticated +initiating-principal projection or User/Tenant membership provider has been +admitted. Request flags, even `verified=true`, cannot change that constant. +Activating this package today denies every request. It does not replace lifecycle +v2. Any successor that changes owner_admission requires review of the actual +server injection boundary and authoritative providers, not a values switch. + +The candidate predicate models the future join, not a trustworthy caller claim. +It is exercised with synthetic server projections in tests. Raw human tokens +must never enter CheckRequest/context/decision records. See the accompanying +source verifier and provider contract; neither is wired into HTTP evaluation. + +```rego +owner_admission := false + +default decision := {"effect": "deny", "reason": "initiating_provider_unadmitted"} + +decision := {"effect": "allow", "reason": "exact_task_execution_matched"} { + owner_admission + candidate_matches +} + +hash_ok(x) { is_string(x); regex.match("^sha256:[0-9a-f]{64}$", x) } + +candidate_matches { + input.action == "execute-ccr" + input.tenant == "tenant:platform" + input.subject.id == "secrets-engine" + input.subject.type == "service" + input.resource.system == "secrets-engine" + input.resource.type == "secret-catalog-lane" + input.context.human_control == true + c := input.context.execution_contract + c.contract_version == "SECRETS-EXEC-20261011-v1" + c.operation == "metadata-apply-ccr" + c.execution_actor == "service:secrets-engine" + regex.match("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", c.operation_id) + c.ccr_id != c.execution_grant_ccr_id + hash_ok(c.ccr_immutable_sha256) + hash_ok(c.grant_immutable_sha256) + hash_ok(c.catalog_sha256) + hash_ok(c.expected_before) + hash_ok(c.expected_after) + c.backend_budget.ttl_seconds <= 120 + c.backend_budget.ttl_seconds >= 30 + c.backend_budget.uses == 12 + count(c.operations) == 6 + c.operations[0].method == "GET" + c.operations[1].method == "PUT" + c.operations[2].method == "GET" + c.operations[3].method == "GET" + c.operations[4].method == "PUT" + c.operations[5].method == "GET" + regex.match("^sys/policies/acl/[A-Za-z0-9_-]+$", c.operations[0].path) + regex.match("^auth/[A-Za-z0-9_-]+/role/[A-Za-z0-9_-]+$", c.operations[3].path) + c.operations[0].path == c.operations[1].path + c.operations[1].path == c.operations[2].path + c.operations[3].path == c.operations[4].path + c.operations[4].path == c.operations[5].path + every_operation_hash_valid + p := input.context.task_execution_projection + p.version == "proposed-task-execution-projection/v1" + p.principal_type == "human" + p.subject != "" + p.tenant == input.tenant + p.tenant_source == "directory" + p.mfa == true + p.transport_principal == "system:serviceaccount:secrets-engine:secrets-engine" + p.execution_contract_digest == concat("", ["sha256:", crypto.sha256(json.marshal(c))]) + p.resource_id == input.resource.id + p.membership_source == "user-engine" + p.tenant_state_source == "tenant-engine" + p.member_active == true + p.tenant_active == true + p.not_after_ns > time.now_ns() + p.not_after_ns <= time.parse_rfc3339_ns(c.operation_deadline) + input.context.approval.kind == "approval-claim" + input.context.approval.binding.human_control == true +} + +every_operation_hash_valid { + count([x | x := input.context.execution_contract.operations[_]; hash_ok(x.payload_sha256)]) == 6 +} +``` + +```rego test +import data.flexauth.secrets_engine.task_execution + +test_unsigned_admission_flags_cannot_open { + task_execution.decision == {"effect": "deny", "reason": "initiating_provider_unadmitted"} + with input as {"context": {"verified": true, "owner_admission": true, "human_control": true}} +} +``` diff --git a/internal/policy/task_execution_test.go b/internal/policy/task_execution_test.go new file mode 100644 index 0000000..c43e111 --- /dev/null +++ b/internal/policy/task_execution_test.go @@ -0,0 +1,104 @@ +package policy_test + +import ( + "context" + "encoding/json" + "github.com/netkingdom/access-engine/internal/policy" + "github.com/netkingdom/access-engine/pkg/api" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestTaskExecutionPackageClosedAdmission(t *testing.T) { + p, e := policy.LoadAndValidateFile(context.Background(), filepath.Join("..", "..", "examples", "secrets-engine-task-execution", "policy_package.md")) + if e != nil || !p.Valid { + t.Fatalf("package: %v %+v", e, p.Validation) + } + for _, action := range []string{"execute-ccr", "apply", "exec"} { + r := api.CheckRequest{Tenant: "tenant:platform", Subject: api.SubjectRef{ID: "secrets-engine", Type: "service"}, Action: action, Resource: api.ResourceRef{ID: "synthetic", Type: "secret-catalog-lane", System: "secrets-engine"}, Context: map[string]any{"owner_admission": true, "verified": true, "human_control": true, "task_execution_projection": map[string]any{"verified": true}}} + d, e := p.Evaluate(context.Background(), r) + if e != nil || d.Effect != api.DecisionEffectDeny { + t.Fatalf("unsigned caller opened admission: %v %+v", e, d) + } + } +} +func TestTaskExecutionSourceHasNoActivationSwitch(t *testing.T) { + raw, e := os.ReadFile(filepath.Join("..", "..", "examples", "secrets-engine-task-execution", "policy_package.md")) + if e != nil { + t.Fatal(e) + } + if !strings.Contains(string(raw), "owner_admission := false") { + t.Fatal("source refusal gate absent") + } +} + +func TestTaskExecutionPythonDescriptorAndSyntheticFutureProjection(t *testing.T) { + output, e := exec.Command("/home/worsch/secrets-engine/.venv/bin/python", "../../tools/task_execution_interop_fixture.py").Output() + if e != nil { + t.Fatal("Python actual descriptor/canonical generator failed:", e) + } + var fixture struct { + Request api.CheckRequest `json:"request"` + RequestDigest string `json:"request_digest"` + ApprovalDigest string `json:"approval_binding_digest"` + DescriptorDigest string `json:"descriptor_digest"` + } + if json.Unmarshal(output, &fixture) != nil { + t.Fatal("fixture JSON") + } + r := fixture.Request + if api.RequestDigest(r) != fixture.RequestDigest || api.ApprovalBindingDigest(r) != fixture.ApprovalDigest || api.CanonicalDigest(r.Context["execution_contract"]) != fixture.DescriptorDigest { + t.Fatal("actual Python/Go canonical mismatch") + } + raw, e := os.ReadFile(filepath.Join("..", "..", "examples", "secrets-engine-task-execution", "policy_package.md")) + if e != nil { + t.Fatal(e) + } + // This source replacement is intentionally test-only; no caller/config flag + // can change the production package's hard admission=false constant. + raw = []byte(strings.Replace(string(raw), "owner_admission := false", "owner_admission := true", 1)) + p, e := policy.Load(raw, "synthetic-future-provider-test.md") + if e != nil { + t.Fatal(e) + } + d, e := p.Evaluate(context.Background(), r) + if e != nil || d.Effect != api.DecisionEffectAllow { + t.Fatalf("synthetic future projection did not match: %v %+v", e, d) + } + cases := map[string]func(*api.CheckRequest){ + "action": func(r *api.CheckRequest) { r.Action = "apply" }, "service subject": func(r *api.CheckRequest) { r.Subject.ID = "other" }, "foreign tenant": func(r *api.CheckRequest) { r.Tenant = "tenant:coulomb" }, + "plan hash": func(r *api.CheckRequest) { + r.Context["execution_contract"].(map[string]any)["expected_after"] = "sha256:" + strings.Repeat("b", 64) + }, + "unsigned human": func(r *api.CheckRequest) { delete(r.Context, "task_execution_projection") }, + "registered tenant": func(r *api.CheckRequest) { + r.Context["task_execution_projection"].(map[string]any)["tenant_source"] = "registration" + }, + "membership": func(r *api.CheckRequest) { + r.Context["task_execution_projection"].(map[string]any)["member_active"] = false + }, + "transport": func(r *api.CheckRequest) { + r.Context["task_execution_projection"].(map[string]any)["transport_principal"] = "other" + }, + "expiry": func(r *api.CheckRequest) { + r.Context["task_execution_projection"].(map[string]any)["not_after_ns"] = float64(1) + }, + "no human control": func(r *api.CheckRequest) { r.Context["human_control"] = false }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + var f struct { + Request api.CheckRequest `json:"request"` + } + json.Unmarshal(output, &f) + mutate(&f.Request) + d, e := p.Evaluate(context.Background(), f.Request) + if e != nil || d.Effect != api.DecisionEffectDeny { + t.Fatalf("mutation allowed: %v %+v", e, d) + } + }) + } +} diff --git a/internal/taskexecution/identity.go b/internal/taskexecution/identity.go new file mode 100644 index 0000000..5fca861 --- /dev/null +++ b/internal/taskexecution/identity.go @@ -0,0 +1,168 @@ +// Package taskexecution contains proposed source-only identity input validation. +// It is not registered with HTTP evaluation and grants no executable admission. +package taskexecution + +import ( + "bytes" + "crypto" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "strings" + "time" +) + +var ErrIdentity = errors.New("task execution initiating identity refused") + +// IssuerPins must come from reviewed owner configuration, never CheckRequest. +// No production pins or default issuer/client are supplied by this package. +type IssuerPins struct { + Issuer, Audience string + Keys map[string]*rsa.PublicKey +} + +// Initiator has no exported constructor or mutable fields. Verification proves +// identity only, not membership, representation, approval or policy allowance. +type Initiator struct { + subject string + expires time.Time +} + +func (i Initiator) Subject() string { return i.subject } +func (i Initiator) Expires() time.Time { return i.expires } + +// strictObject rejects duplicate names (including nested ones), trailing JSON +// and excessive depth. JWT claims are never copied to logs or CheckRequest. +func strictObject(raw []byte, out any) error { + d := json.NewDecoder(bytes.NewReader(raw)) + depth := 0 + var walk func() error + walk = func() error { + depth++ + defer func() { depth-- }() + if depth > 16 { + return ErrIdentity + } + t, e := d.Token() + if e != nil { + return ErrIdentity + } + delim, ok := t.(json.Delim) + if !ok { + return nil + } + switch delim { + case '{': + seen := map[string]bool{} + for d.More() { + k, e := d.Token() + s, ok := k.(string) + if e != nil || !ok || seen[s] { + return ErrIdentity + } + seen[s] = true + if walk() != nil { + return ErrIdentity + } + } + case '[': + for d.More() { + if walk() != nil { + return ErrIdentity + } + } + default: + return ErrIdentity + } + _, e = d.Token() + return e + } + if walk() != nil { + return ErrIdentity + } + if _, e := d.Token(); e == nil { + return ErrIdentity + } + if json.Unmarshal(raw, out) != nil { + return ErrIdentity + } + return nil +} + +// VerifyKeyCapeHuman reuses KeyCape's RS256 access-token claim contract. Pins +// and trusted time are server inputs. No URL/key discovery from token headers. +func VerifyKeyCapeHuman(token string, pins IssuerPins, now time.Time) (Initiator, error) { + refuse := func() (Initiator, error) { return Initiator{}, ErrIdentity } + if len(token) > 16384 || len(token) == 0 || pins.Issuer == "" || pins.Audience == "" || now.IsZero() { + return refuse() + } + parts := strings.Split(token, ".") + if len(parts) != 3 { + return refuse() + } + decode := func(s string) ([]byte, error) { return base64.RawURLEncoding.Strict().DecodeString(s) } + header, e := decode(parts[0]) + if e != nil { + return refuse() + } + payload, e := decode(parts[1]) + if e != nil { + return refuse() + } + signature, e := decode(parts[2]) + if e != nil { + return refuse() + } + var h map[string]any + if strictObject(header, &h) != nil || h["alg"] != "RS256" { + return refuse() + } + kid, ok := h["kid"].(string) + if !ok { + return refuse() + } + for k := range h { + if k != "alg" && k != "kid" && k != "typ" { + return refuse() + } + } + key := pins.Keys[kid] + if key == nil || key.N == nil || key.N.BitLen() < 2048 || key.E < 3 { + return refuse() + } + digest := sha256.Sum256([]byte(parts[0] + "." + parts[1])) + if rsa.VerifyPKCS1v15(key, crypto.SHA256, digest[:], signature) != nil { + return refuse() + } + var c struct { + Issuer string `json:"iss"` + Audience json.RawMessage `json:"aud"` + Subject string `json:"sub"` + Principal string `json:"principal_type"` + Tenant string `json:"tenant"` + TenantSource string `json:"tenant_source"` + Expires int64 `json:"exp"` + Issued int64 `json:"iat"` + NotBefore int64 `json:"nbf"` + Assurance struct { + Level string `json:"level"` + MFA bool `json:"mfa"` + Source string `json:"source"` + At int64 `json:"at"` + } `json:"assurance"` + } + if strictObject(payload, &c) != nil { + return refuse() + } + var aud string + if json.Unmarshal(c.Audience, &aud) != nil || aud != pins.Audience { + return refuse() + } + n := now.Unix() + if c.Issuer != pins.Issuer || c.Subject == "" || len(c.Subject) > 512 || strings.ContainsAny(c.Subject, "\r\n") || c.Principal != "human" || c.Tenant != "tenant:platform" || c.TenantSource != "directory" || c.Expires <= n || c.Issued > n || c.Issued <= 0 || c.NotBefore > n || c.Expires <= c.Issued || c.Assurance.Level != "aal2" || !c.Assurance.MFA || c.Assurance.Source != "key-cape" || c.Assurance.At <= 0 || c.Assurance.At > n || n-c.Assurance.At > 300 { + return refuse() + } + return Initiator{subject: c.Subject, expires: time.Unix(c.Expires, 0)}, nil +} diff --git a/internal/taskexecution/identity_test.go b/internal/taskexecution/identity_test.go new file mode 100644 index 0000000..edb33ce --- /dev/null +++ b/internal/taskexecution/identity_test.go @@ -0,0 +1,61 @@ +package taskexecution + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "testing" + "time" +) + +func TestCryptographicInitiatorContract(t *testing.T) { + key, e := rsa.GenerateKey(rand.Reader, 2048) + if e != nil { + t.Fatal(e) + } + now := time.Now() + pins := IssuerPins{Issuer: "https://synthetic.invalid", Audience: "synthetic-task-test", Keys: map[string]*rsa.PublicKey{"synthetic": &key.PublicKey}} + claims := func() map[string]any { + return map[string]any{"iss": pins.Issuer, "aud": pins.Audience, "sub": "synthetic-human", "principal_type": "human", "tenant": "tenant:platform", "tenant_source": "directory", "iat": now.Unix() - 1, "exp": now.Unix() + 60, "assurance": map[string]any{"level": "aal2", "mfa": true, "source": "key-cape", "at": now.Unix() - 1}} + } + sign := func(c map[string]any) string { + h, _ := json.Marshal(map[string]any{"alg": "RS256", "kid": "synthetic"}) + b, _ := json.Marshal(c) + v := base64.RawURLEncoding.EncodeToString(h) + "." + base64.RawURLEncoding.EncodeToString(b) + d := sha256.Sum256([]byte(v)) + s, _ := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, d[:]) + return v + "." + base64.RawURLEncoding.EncodeToString(s) + } + token := sign(claims()) + i, e := VerifyKeyCapeHuman(token, pins, now) + if e != nil || i.Subject() != "synthetic-human" { + t.Fatal("synthetic signature verification failed") + } + cases := map[string]func(map[string]any){"service": func(c map[string]any) { c["principal_type"] = "service" }, "registered tenant": func(c map[string]any) { c["tenant_source"] = "registration" }, "foreign tenant": func(c map[string]any) { c["tenant"] = "tenant:coulomb" }, "issuer": func(c map[string]any) { c["iss"] = "https://wrong.invalid" }, "audience": func(c map[string]any) { c["aud"] = "approval-engine" }, "expired": func(c map[string]any) { c["exp"] = now.Unix() }, "future": func(c map[string]any) { c["nbf"] = now.Unix() + 1 }, "no mfa": func(c map[string]any) { c["assurance"].(map[string]any)["mfa"] = false }, "old mfa": func(c map[string]any) { c["assurance"].(map[string]any)["at"] = now.Unix() - 301 }} + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + c := claims() + mutate(c) + if _, e := VerifyKeyCapeHuman(sign(c), pins, now); e == nil { + t.Fatal("invalid human accepted") + } + }) + } + if _, e := VerifyKeyCapeHuman(token, IssuerPins{}, now); e == nil { + t.Fatal("unadmitted issuer accepted") + } + if _, e := VerifyKeyCapeHuman(token+"x", pins, now); e == nil { + t.Fatal("tampered signature accepted") + } +} +func TestStrictClaimJSON(t *testing.T) { + for _, raw := range []string{`{"iss":"a","iss":"b"}`, `{"assurance":{"mfa":true,"mfa":false}}`, `{"x":1} {"x":2}`} { + var v map[string]any + if strictObject([]byte(raw), &v) == nil { + t.Fatal("ambiguous claims accepted") + } + } +} diff --git a/tools/task_execution_interop_fixture.py b/tools/task_execution_interop_fixture.py new file mode 100644 index 0000000..0b29884 --- /dev/null +++ b/tools/task_execution_interop_fixture.py @@ -0,0 +1,13 @@ +"""Synthetic interoperability generator; never obtains credentials or native state.""" +import hashlib, json, sys +from datetime import datetime, timedelta, timezone +sys.path.insert(0, '/home/worsch/secrets-engine/src') +from secrets_engine.execution_contract import ExecutionContract, catalog_target_digest +from secrets_engine.authorization import request_digest, approval_binding_digest +h='sha256:'+'a'*64 +catalog={'lane':'synthetic','stage':'prod'} +c={'contract_version':'SECRETS-EXEC-20261011-v1','operation_id':'cbfbced0-09d5-48c8-a1e7-a2ea4dc69153','operation':'metadata-apply-ccr','execution_actor':'service:secrets-engine','ccr_id':'CCR-2099-0001','execution_grant_ccr_id':'CCR-2099-0002','ccr_immutable_sha256':h,'grant_immutable_sha256':h,'catalog_sha256':catalog_target_digest(catalog),'expected_before':h,'expected_after':h,'source_bundle':{r:{'path':'/synthetic/'+r+'.py','sha256':h} for r in ['engine_dispatcher','platform_mediator','typed_ccr','policy_auth']},'backend_origin':'http://127.0.0.1:18200','cluster_uid':'synthetic-not-native','backend_budget':{'ttl_seconds':120,'uses':12},'operation_deadline':(datetime.now(timezone.utc)+timedelta(seconds=100)).isoformat(),'operations':[{'method':method,'path':path,'payload_sha256':h} for path in ['sys/policies/acl/synthetic','auth/synthetic/role/synthetic'] for method in ['GET','PUT','GET']]} +c=ExecutionContract.parse(c).as_dict() +p={'version':'proposed-task-execution-projection/v1','subject':'synthetic-human','principal_type':'human','tenant':'tenant:platform','tenant_source':'directory','mfa':True,'transport_principal':'system:serviceaccount:secrets-engine:secrets-engine','execution_contract_digest':catalog_target_digest(c),'resource_id':'catalog:synthetic','membership_source':'user-engine','tenant_state_source':'tenant-engine','member_active':True,'tenant_active':True,'not_after_ns':int((datetime.now(timezone.utc)+timedelta(seconds=60)).timestamp())*1000000000} +r={'tenant':'tenant:platform','subject':{'id':'secrets-engine','type':'service'},'action':'execute-ccr','resource':{'id':'catalog:synthetic','type':'secret-catalog-lane','system':'secrets-engine'},'context':{'execution_contract':c,'catalog_target':catalog,'human_control':True,'task_execution_projection':p,'approval':{'kind':'approval-claim','binding':{'human_control':True}}}} +print(json.dumps({'request':r,'request_digest':request_digest(r),'approval_binding_digest':approval_binding_digest(r),'descriptor_digest':catalog_target_digest(c)}))