diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a5b1a6e89..e9feb9c6d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Noema Architecture & Trust Boundaries -**Status: Proposed canonical documentation — In review on PR #71.** Until #71 integrates, protected source and live GitHub governance remain implementation authority. This document describes protected behavior unless explicitly marked **Planned** or **External evidence**. +**Status: Canonical documentation on protected `main`; this branch records Active PR #426 candidate behavior separately.** Protected source and live GitHub governance remain implementation authority. This document describes protected behavior unless explicitly marked **Active PR**, **Planned**, or **External evidence**. Noema is a bounded credential-exchange and automation service. Its core rule is: **verify GitHub Actions OIDC identity, mint a repository-scoped GitHub App installation token, and keep model judgement, review evidence, merge authority, release authority, and deployment authority separate.** @@ -26,11 +26,11 @@ Routes have different meanings: `/health` is liveness, `/ready` is offline confi ## 2. Current workflow trust contract -Protected runtime exposes `ALLOWED_WORKFLOW_REF_PREFIX`. Despite the legacy name, `src/worker.ts` parses it as one **exact full workflow ref** and compares decoded `job_workflow_ref` or `workflow_ref` for exact equality. Wildcard, comma, whitespace, and prefix-sharing configuration forms are rejected. +Protected `main` exposes `ALLOWED_WORKFLOW_REF_PREFIX`. Despite the legacy name, `src/worker.ts` parses it as one **exact full workflow ref** and compares decoded `job_workflow_ref` or `workflow_ref` for exact equality. Wildcard, comma, whitespace, and prefix-sharing configuration forms are rejected. -The protected runtime does not expose `ALLOWED_WORKFLOW_SHA`, `workflow_sha`, or `job_workflow_sha` in this wrapper contract. Therefore canonical documentation must not claim paired SHA validation is deployed. Cryptographic OIDC verification in `src/index.ts` is a separate authority from the outer exact-ref precheck. +**Active PR #426:** the candidate runtime adds `ALLOWED_WORKFLOW_SHA` as a required readiness binding and pairs it with GitHub OIDC `job_workflow_sha` or fallback `workflow_sha`. `src/runtime-entrypoint.ts` provides an early denial-only check for the configured exact workflow identity, while `src/index.ts` independently enforces the same exact ref/repository plus immutable source SHA after cryptographic verification. A missing, malformed, or mismatched configured source SHA fails closed. The candidate `wrangler.toml` pins `ALLOWED_WORKFLOW_SHA` to a specific central `.github` commit; that candidate configuration is not deployed truth until the PR integrates and deployment evidence proves the binding was rolled forward. -**Planned:** a stronger immutable workflow-source binding may be introduced only by a separately reviewed source change with realistic RED/GREEN regressions and deployment/configuration evidence. That stronger immutable workflow-source binding is **not implemented on protected main** merely because historical branch prose described it. +The source-SHA prefilter is not an authorization substitute for cryptographic verification. Tokens not rejected at the wrapper continue through the existing distributed rate-limit, exact-ref trust, signature, issuer, audience, repository, time-window, replay, and GitHub App boundaries. ## 3. Runtime data flow @@ -40,10 +40,11 @@ flowchart LR B --> C{route} C -->|/health| H[Liveness] C -->|/ready| R[Readiness] - C -->|/exchange| E[src/entrypoint.ts] + C -->|/exchange| S{Active PR #426\nexact ref + source SHA prefilter} + S --> E[src/entrypoint.ts] E --> L[NoemaRateLimiter] L --> W[src/worker.ts\nexact workflow ref] - W --> O[src/index.ts\nOIDC verification] + W --> O[src/index.ts\ncryptographic OIDC + exact source binding] O --> G[GitHub App token exchange] G --> P[NoemaOidcReplayGuard] P --> T[Repository-scoped token] @@ -89,7 +90,7 @@ Repository automation must: 6. reject stale/predecessor evidence as current success; 7. avoid self-modifying repair workflows and never weaken gates to manufacture green evidence. -These control-plane invariants are separate from the OIDC runtime's current exact-ref trust contract. +These control-plane invariants are separate from the runtime OIDC trust contract. Active PR #426 strengthens that runtime contract with immutable workflow-source identity but does not alter the evidence-authority rules above. ## 7. Credential and network boundaries @@ -119,7 +120,7 @@ Durable Object alarms are at-least-once. Handlers reread current deadline/expiry | Change | Minimum proof | | --- | --- | | `/exchange` | typecheck, realistic public/API regressions, exact owned-production coverage, security scan | -| OIDC/GitHub App | issuer/audience/repository/workflow-ref, malformed token/JWKS, replay, redirect/egress, secret non-disclosure regressions | +| OIDC/GitHub App | issuer/audience/repository/workflow-ref, immutable workflow-source SHA when configured, malformed token/JWKS, replay, redirect/egress, secret non-disclosure regressions | | Durable Objects | cross-instance semantics, delayed/retried alarm, current-state reschedule, malformed backend/storage-failure tests | | GitHub Actions/control plane | least privilege, exact-head/live-base binding, full pagination, stale-head refusal, evidence-class separation | | LLM integration | gateway contract, provider-key isolation, deterministic gates independent of model judgement | diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index cc207f0f4..cb57689e3 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -4,7 +4,7 @@ This document maps requirements and architecture decisions to executable Noema surfaces and to the evidence that can legitimately prove them. File presence, PR prose, model output, queued checks, or predecessor results are never promoted into implementation, approval, merge, release, deployment, or acquisition authority. -Current protected-main reference for this refresh: `1ac1ccb7829a87f3e13c531db0765a7ed1e00002`. +Current protected-main reference for this refresh: `bcef225f1cf1a640a78a7c5b55b662cc5deb8ef4`. Noema's execution rule is: @@ -42,7 +42,7 @@ Each arrow is a separate authority. Success at an earlier stage cannot fabricate | Requirement family | Canonical decision / boundary | Protected or active implementation surface | Executable proof | Residual evidence | Maturity | | --- | --- | --- | --- | --- | --- | | Credential exchange and readiness | Architecture, runtime threat model | `src/index.ts`, runtime entrypoints, OIDC/replay/rate-limit modules | runtime/API/security tests and exact configured coverage | deployed protected-main smoke where applicable | Implemented on protected main; operational evidence remains separate | -| Workflow/repository authority | Runtime threat model and protected Worker contract | configured exact workflow-ref and repository-owner validation plus cryptographic OIDC verification | issuer/audience/repository/ref and hostile-token tests | current central workflow/deployment binding evidence | Implemented family; do not invent a separate SHA binding that protected runtime does not expose | +| Workflow/repository authority | Runtime threat model and Worker trust contract | protected exact workflow-ref and repository-owner validation plus cryptographic OIDC verification; Active PR #426 adds immutable `ALLOWED_WORKFLOW_SHA` binding to `job_workflow_sha` / `workflow_sha` | issuer/audience/repository/ref hostile-token tests plus #426 source-SHA mismatch/missing/configuration regressions | exact-head #426 CI/security evidence, current central workflow identity, protected deployment binding evidence | Exact-ref family implemented on protected main; immutable source-SHA binding Implemented on active PR / In review | | Fail-closed outbound GitHub boundary | Architecture + security docs | outbound fetch/request/response validation | origin/redirect/timeout/body/schema tests | production telemetry/incident evidence | Implemented family | | Delegated GitHub credential capability | AGENTS secret policy + closed issue #111 | `scripts/lib/delegated-github-token.mjs`, maintainer/reviewer workflow ingress | token-capability and workflow-ingress tests covering `NOEMA_MAINTAINER_TOKEN_PATH`, owner-only `0600`, symlink/race/size/content rejection, minimal child env | live App installation/key-custody/rotation/permission evidence under #29/#227 | Capability-file policy alignment is protected; external identity evidence remains separate | | Distributed rate/replay state | Architecture data boundary | Durable Object rate/replay state | concurrency/alarm/replay tests | deployed binding/storage evidence | Implemented family | @@ -72,6 +72,7 @@ Historical PR numbers are deliberately omitted unless they are still open and ma | Workstream | Current owner | Evidence boundary | | --- | --- | --- | +| Immutable OIDC workflow-source binding | PR #426 | Active candidate only; exact-head application/reviewer/Security evidence and later protected deployment configuration must prove the source-SHA binding before it becomes protected/deployed truth. | | Patch-validator image verification | issue #66 / PR #407 | Current image owner; standard and dedicated image evidence must pass on one unchanged exact head before integration. | | Historical validator-image stack | PR #67 | Stale predecessor retained only until #407 integration and unique-delta preservation/supersession are proven. | @@ -185,4 +186,4 @@ After every material product, governance, persistence, stack, release, or operat 3. update the single canonical graph on its current owner branch; 4. remove stale owner tables and obsolete SHAs rather than accumulating them; 5. keep transient check conclusions out of timeless claims unless explicitly observation-scoped; -6. convert any newly discovered executable defect to its real source/test/API/operator owner before considering the documentation refresh complete. \ No newline at end of file +6. convert any newly discovered executable defect to its real source/test/API/operator owner before considering the documentation refresh complete. diff --git a/src/index.ts b/src/index.ts index e6db0bea1..d50a080ea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ export interface Env extends OidcReplayProtectionEnv { ALLOWED_REPOSITORY_OWNER: string; ALLOWED_WORKFLOW_REPOSITORY: string; ALLOWED_WORKFLOW_REF_PREFIX: string; + ALLOWED_WORKFLOW_SHA?: string; GITHUB_API_BASE: string; GITHUB_APP_ID: string; GITHUB_APP_PRIVATE_KEY_PEM: string; @@ -32,7 +33,9 @@ type JwtPayload = { repository?: string; repository_owner?: string; workflow_ref?: string; + workflow_sha?: string; job_workflow_ref?: string; + job_workflow_sha?: string; sub?: string; ref?: string; jti?: string; @@ -137,6 +140,7 @@ const errorHints: Record = { const trustedHeaderValuePattern = /^[A-Za-z0-9._:-]+$/; const clientIdentifierPattern = /^[A-Za-z0-9.:%_,-]+$/; +const exactWorkflowSourceShaPattern = /^[0-9a-f]{40}$/; const maxTrustedHeaderLength = 128; function jsonResponse(body: StandardErrorResponse | StandardSuccessResponse, status = 200): Response { @@ -429,6 +433,24 @@ async function verifyGithubOidcJwt(token: string, env: Env): Promise if (!workflowRef.startsWith(`${env.ALLOWED_WORKFLOW_REPOSITORY}/.github/workflows/`)) { throw new ApiError("ERR_WORKFLOW_NOT_ALLOWED", 403, "OIDC workflow repository is not allowed"); } + const configuredWorkflowSha = env.ALLOWED_WORKFLOW_SHA?.trim(); + if (!configuredWorkflowSha || !exactWorkflowSourceShaPattern.test(configuredWorkflowSha)) { + throw new ApiError( + "ERR_WORKFLOW_NOT_ALLOWED", + 503, + "Workflow source trust configuration unavailable", + { match_policy: "exact-ref-and-source-sha" }, + ); + } + const workflowSha = payload.job_workflow_ref ? payload.job_workflow_sha : payload.workflow_sha; + if (workflowSha !== configuredWorkflowSha) { + throw new ApiError( + "ERR_WORKFLOW_NOT_ALLOWED", + 403, + "OIDC workflow source revision is not allowed", + { match_policy: "exact-ref-and-source-sha" }, + ); + } if (typeof payload.nbf === "number" && payload.nbf > now + 30) { throw new ApiError("ERR_AUTH_INVALID", 401, "OIDC token is not valid yet"); } diff --git a/src/runtime-entrypoint.ts b/src/runtime-entrypoint.ts index 2006bd199..57cf68dd7 100644 --- a/src/runtime-entrypoint.ts +++ b/src/runtime-entrypoint.ts @@ -10,9 +10,128 @@ export { NoemaOidcReplayGuard, NoemaRateLimiter }; /** * Runtime bindings required by Noema's production worker entrypoint. * This interface inherits the credential-exchange, replay-guard, and rate-limit bindings - * consumed by the delegated application entrypoint and the readiness evaluation path. + * consumed by the delegated application entrypoint and adds the immutable source revision + * expected for the configured central reusable workflow. */ -export interface Env extends BaseEnv {} +export interface Env extends BaseEnv { + ALLOWED_WORKFLOW_SHA?: string; +} + +const exactCommitShaPattern = /^[0-9a-f]{40}$/; +const MAX_OIDC_PAYLOAD_SEGMENT_LENGTH = 8_192; + +type ReusableWorkflowClaims = { + workflow_ref?: unknown; + workflow_sha?: unknown; + job_workflow_ref?: unknown; + job_workflow_sha?: unknown; +}; + +type WorkflowSourceDecision = + | { allowed: true } + | { + allowed: false; + status: 403 | 503; + message: string; + hint: string; + outcome: "blocked" | "misconfigured"; + }; + +function decodedReusableWorkflowClaims(request: Request): ReusableWorkflowClaims | undefined { + const authorization = request.headers.get("authorization") ?? ""; + const match = authorization.match(/^Bearer\s+(\S+)$/i); + if (!match) return undefined; + + const parts = match[1].split("."); + if (parts.length !== 3 || parts[1].length > MAX_OIDC_PAYLOAD_SEGMENT_LENGTH) { + return undefined; + } + + try { + const normalized = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const padded = normalized + "===".slice((normalized.length + 3) % 4); + const binary = atob(padded); + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)); + const decoded: unknown = JSON.parse(new TextDecoder().decode(bytes)); + if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) { + return undefined; + } + return decoded as ReusableWorkflowClaims; + } catch { + return undefined; + } +} + +function workflowSourceDecision(request: Request, env: Env): WorkflowSourceDecision { + const claims = decodedReusableWorkflowClaims(request); + if (!claims) return { allowed: true }; + + const usingReusableWorkflowIdentity = typeof claims.job_workflow_ref === "string"; + const workflowRef = usingReusableWorkflowIdentity + ? claims.job_workflow_ref + : typeof claims.workflow_ref === "string" + ? claims.workflow_ref + : undefined; + if (!workflowRef) return { allowed: true }; + + const configuredRef = env.ALLOWED_WORKFLOW_REF_PREFIX?.trim(); + if (!configuredRef || workflowRef !== configuredRef) { + // The delegated hardened worker owns the exact workflow-ref error contract. + return { allowed: true }; + } + + const configuredSha = env.ALLOWED_WORKFLOW_SHA?.trim(); + if (!configuredSha || !exactCommitShaPattern.test(configuredSha)) { + return { + allowed: false, + status: 503, + message: "Workflow source trust configuration unavailable", + hint: "Configure the exact 40-character lowercase commit SHA for the allowed workflow source.", + outcome: "misconfigured", + }; + } + + const workflowSha = usingReusableWorkflowIdentity + ? claims.job_workflow_sha + : claims.workflow_sha; + if (workflowSha !== configuredSha) { + return { + allowed: false, + status: 403, + message: "OIDC workflow source revision is not allowed", + hint: "Run the request from the configured workflow source revision; mutable-ref identity alone is insufficient.", + outcome: "blocked", + }; + } + + return { allowed: true }; +} + +function workflowSourceResponse( + decision: Exclude, +): Response { + const traceId = crypto.randomUUID(); + return new Response(JSON.stringify({ + ok: false, + error_code: "ERR_WORKFLOW_NOT_ALLOWED", + message: decision.message, + details: { + hint: decision.hint, + match_policy: "exact-ref-and-source-sha", + }, + trace_id: traceId, + }), { + status: decision.status, + headers: { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + pragma: "no-cache", + "x-content-type-options": "nosniff", + "x-trace-id": traceId, + "x-latency-ms": "0", + }, + }); +} function readinessHeaders( traceId: string, @@ -92,8 +211,11 @@ async function runtimeReadinessResponse(request: Request, env: Env): Promise { @@ -101,6 +223,21 @@ export default { if (url.pathname === "/ready") { return runtimeReadinessResponse(request, env); } + if (url.pathname === "/exchange") { + const sourceDecision = workflowSourceDecision(request, env); + if (!sourceDecision.allowed) { + console.log(JSON.stringify({ + event: "workflow_source_trust", + route: url.pathname, + method: request.method, + status_code: sourceDecision.status, + error_code: "ERR_WORKFLOW_NOT_ALLOWED", + outcome: sourceDecision.outcome, + match_policy: "exact-ref-and-source-sha", + })); + return workflowSourceResponse(sourceDecision); + } + } return entrypoint.fetch(request, env); }, }; diff --git a/src/runtime-readiness.ts b/src/runtime-readiness.ts index 905dbf50e..62e53114b 100644 --- a/src/runtime-readiness.ts +++ b/src/runtime-readiness.ts @@ -5,6 +5,7 @@ const trustedOwnerPattern = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/; const positiveDecimalPattern = /^[1-9][0-9]*$/; const privateKeyPattern = /^-----BEGIN PRIVATE KEY-----\r?\n([A-Za-z0-9+/=\r\n]+)\r?\n-----END PRIVATE KEY-----$/; const exactCommitPattern = /^[0-9a-fA-F]{40}$/; +const exactWorkflowShaPattern = /^[0-9a-f]{40}$/; const trustedNamedRefPattern = /^refs\/(?:heads|tags)\/(?=.{1,1024}$)(?!\.)(?![^/]*\.lock(?:\/|$))(?!.*\/\.)(?!.*\/[^/]*\.lock(?:\/|$))(?!.*(?:\.\.|\/\/|@\{|\\|[\x00-\x20\x7f~^:?*\[]))(?!.*[\/.]$)[A-Za-z0-9._/-]+$/; /** @@ -20,6 +21,7 @@ export type RuntimeReadinessFailure = | "allowed_repository_owner" | "allowed_workflow_repository" | "allowed_workflow_ref" + | "allowed_workflow_sha" | "github_api_base" | "github_app_id" | "github_app_private_key" @@ -41,6 +43,7 @@ export interface RuntimeReadinessEnv { ALLOWED_REPOSITORY_OWNER?: string; ALLOWED_WORKFLOW_REPOSITORY?: string; ALLOWED_WORKFLOW_REF_PREFIX?: string; + ALLOWED_WORKFLOW_SHA?: string; GITHUB_API_BASE?: string; GITHUB_APP_ID?: string; GITHUB_APP_PRIVATE_KEY_PEM?: string; @@ -135,12 +138,13 @@ function cachedPrivateKeyImportability(env: RuntimeReadinessEnv): Promise { } }); - it("binds the architecture description to the deployed Wrangler entrypoint and state classes without inventing a removed SHA binding", () => { + it("binds the candidate architecture description to the deployed Wrangler entrypoint, state classes, and immutable workflow-source configuration", () => { const wrangler = readFileSync("wrangler.toml", "utf8"); const architecture = readFileSync("ARCHITECTURE.md", "utf8"); @@ -38,11 +38,13 @@ describe("authoritative architecture documentation", () => { expect(wrangler).toContain('class_name = "NoemaRateLimiter"'); expect(wrangler).toContain('name = "NOEMA_OIDC_REPLAY_GUARD"'); expect(wrangler).toContain('class_name = "NoemaOidcReplayGuard"'); - expect(wrangler).not.toContain("ALLOWED_WORKFLOW_SHA"); + expect(wrangler).toMatch(/ALLOWED_WORKFLOW_SHA = "[0-9a-f]{40}"/); expect(architecture).toContain("NOEMA_RATE_LIMITER"); expect(architecture).toContain("NOEMA_OIDC_REPLAY_GUARD"); - expect(architecture).toContain("protected runtime does not expose `ALLOWED_WORKFLOW_SHA`"); + expect(architecture).toContain("Active PR #426"); + expect(architecture).toContain("`ALLOWED_WORKFLOW_SHA`"); + expect(architecture).toContain("not deployed truth until the PR integrates"); }); it("keeps route claims anchored to their actual implementation layers", () => { @@ -66,17 +68,23 @@ describe("authoritative architecture documentation", () => { expect(coreWorker).toContain('"Endpoint not found"'); }); - it("documents current exact-ref workflow trust without reviving historical SHA-claim behavior", () => { + it("documents exact-ref workflow trust plus the candidate immutable workflow-source binding without moving that check into the worker wrapper", () => { const architecture = readFileSync("ARCHITECTURE.md", "utf8"); + const runtimeEntrypoint = readFileSync("src/runtime-entrypoint.ts", "utf8"); const worker = readFileSync("src/worker.ts", "utf8"); + const coreWorker = readFileSync("src/index.ts", "utf8"); expect(worker).toContain("configuredExactWorkflowRef"); expect(worker).toContain("workflowRef !== configuredRef"); expect(worker).not.toContain("workflow_sha"); expect(worker).not.toContain("job_workflow_sha"); + expect(runtimeEntrypoint).toContain("ALLOWED_WORKFLOW_SHA"); + expect(runtimeEntrypoint).toContain("workflowSourceDecision"); + expect(coreWorker).toContain("job_workflow_sha"); + expect(coreWorker).toContain("workflow_sha"); expect(architecture).toContain("exact full workflow ref"); - expect(architecture).toContain("stronger immutable workflow-source binding"); - expect(architecture).toContain("not implemented on protected main"); + expect(architecture).toContain("immutable workflow-source SHA"); + expect(architecture).toContain("Active PR #426"); }); it("keeps the canonical documentation audit aligned with integrated buyer/operator documentation", () => { @@ -103,4 +111,4 @@ describe("authoritative architecture documentation", () => { expect(gapAudit).toContain("source defect itself is no longer an open implementation gap"); expect(traceability).toContain("broad V8-ignore introduction = regression"); }); -}); \ No newline at end of file +}); diff --git a/test/credential-request-helper-coverage.test.ts b/test/credential-request-helper-coverage.test.ts index d0b14ccd3..ff31422ca 100644 --- a/test/credential-request-helper-coverage.test.ts +++ b/test/credential-request-helper-coverage.test.ts @@ -3,6 +3,7 @@ import worker, { type Env } from "../src/index"; const configuredRef = "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main"; +const configuredWorkflowSha = "a".repeat(40); const env: Env = { ALLOWED_ISSUER: "https://token.actions.githubusercontent.com", @@ -10,6 +11,7 @@ const env: Env = { ALLOWED_REPOSITORY_OWNER: "ContextualWisdomLab", ALLOWED_WORKFLOW_REPOSITORY: "ContextualWisdomLab/.github", ALLOWED_WORKFLOW_REF_PREFIX: configuredRef, + ALLOWED_WORKFLOW_SHA: configuredWorkflowSha, GITHUB_API_BASE: "https://api.github.com", GITHUB_APP_ID: "1", GITHUB_APP_PRIVATE_KEY_PEM: "unused-before-request-validation", @@ -43,6 +45,7 @@ async function createSignedJwt(repository: string) { repository_owner: env.ALLOWED_REPOSITORY_OWNER, repository, job_workflow_ref: configuredRef, + job_workflow_sha: configuredWorkflowSha, sub: "repo:ContextualWisdomLab/.github:ref:refs/heads/main", exp: now + 300, nbf: now - 30, diff --git a/test/documentation-architecture-contract.test.ts b/test/documentation-architecture-contract.test.ts index 320e412a1..e8fc7dcea 100644 --- a/test/documentation-architecture-contract.test.ts +++ b/test/documentation-architecture-contract.test.ts @@ -38,8 +38,9 @@ describe("authoritative Noema documentation graph", () => { const architecture = document("ARCHITECTURE.md"); const traceability = document("docs/TRACEABILITY.md"); const gapAudit = document("docs/DOCUMENTATION_GAP_AUDIT.md"); - expect(architecture).toContain("Proposed canonical documentation"); - expect(architecture).toContain("not implemented on protected main"); + expect(architecture).toContain("Canonical documentation on protected `main`"); + expect(architecture).toContain("Active PR #426"); + expect(architecture).toContain("candidate configuration is not deployed truth"); expect(traceability).toContain("Implemented on protected main"); expect(traceability).toContain("Implemented on active PR / In review"); expect(traceability).toContain("Planned"); @@ -63,13 +64,14 @@ describe("authoritative Noema documentation graph", () => { expect(gapAudit).toContain("Canonical architecture/documentation | protected main"); }); - it("matches protected exact-ref workflow trust instead of historical SHA claims", () => { + it("keeps protected exact-ref truth separate from active immutable-source trust", () => { const architecture = document("ARCHITECTURE.md"); const traceability = document("docs/TRACEABILITY.md"); expect(architecture).toContain("exact full workflow ref"); - expect(architecture).toContain("protected runtime does not expose `ALLOWED_WORKFLOW_SHA`"); - expect(architecture).toContain("stronger immutable workflow-source binding"); - expect(traceability).toContain("do not invent a separate SHA binding that protected runtime does not expose"); + expect(architecture).toContain("Active PR #426"); + expect(architecture).toContain("`ALLOWED_WORKFLOW_SHA`"); + expect(traceability).toContain("Active PR #426 adds immutable `ALLOWED_WORKFLOW_SHA` binding"); + expect(traceability).toContain("immutable source-SHA binding Implemented on active PR / In review"); }); it("keeps licensing and issue-84 closure fail closed", () => { @@ -82,4 +84,4 @@ describe("authoritative Noema documentation graph", () => { expect(gapAudit).toContain("source defect itself is no longer an open implementation gap"); expect(traceability).toContain("broad V8-ignore introduction = regression"); }); -}); \ No newline at end of file +}); diff --git a/test/exchange-success-path-coverage.test.ts b/test/exchange-success-path-coverage.test.ts index 6c454fe55..5df4dae02 100644 --- a/test/exchange-success-path-coverage.test.ts +++ b/test/exchange-success-path-coverage.test.ts @@ -3,6 +3,7 @@ import worker, { type Env } from "../src/index"; const configuredRef = "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main"; +const configuredWorkflowSha = "a".repeat(40); const env: Env = { ALLOWED_ISSUER: "https://token.actions.githubusercontent.com", @@ -10,6 +11,7 @@ const env: Env = { ALLOWED_REPOSITORY_OWNER: "ContextualWisdomLab", ALLOWED_WORKFLOW_REPOSITORY: "ContextualWisdomLab/.github", ALLOWED_WORKFLOW_REF_PREFIX: configuredRef, + ALLOWED_WORKFLOW_SHA: configuredWorkflowSha, GITHUB_API_BASE: "https://api.github.com", GITHUB_APP_ID: "1", GITHUB_APP_PRIVATE_KEY_PEM: "unused", @@ -69,6 +71,7 @@ describe("exchange success-path coverage through the public worker", () => { repository_owner: env.ALLOWED_REPOSITORY_OWNER, repository: "ContextualWisdomLab/.github", workflow_ref: configuredRef, + workflow_sha: configuredWorkflowSha, exp: now + 300, nbf: now - 30, iat: now - 30, diff --git a/test/github-app-runtime-coverage.test.ts b/test/github-app-runtime-coverage.test.ts index 6605b8b87..8774ea1ea 100644 --- a/test/github-app-runtime-coverage.test.ts +++ b/test/github-app-runtime-coverage.test.ts @@ -3,6 +3,7 @@ import worker, { type Env } from "../src/index"; const configuredRef = "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main"; +const configuredWorkflowSha = "a".repeat(40); const baseEnv: Env = { ALLOWED_ISSUER: "https://token.actions.githubusercontent.com", @@ -10,6 +11,7 @@ const baseEnv: Env = { ALLOWED_REPOSITORY_OWNER: "ContextualWisdomLab", ALLOWED_WORKFLOW_REPOSITORY: "ContextualWisdomLab/.github", ALLOWED_WORKFLOW_REF_PREFIX: configuredRef, + ALLOWED_WORKFLOW_SHA: configuredWorkflowSha, GITHUB_API_BASE: "https://api.github.com", GITHUB_APP_ID: "1", GITHUB_APP_PRIVATE_KEY_PEM: "initialized-in-beforeAll", @@ -71,6 +73,7 @@ async function createOidcToken() { repository_owner: baseEnv.ALLOWED_REPOSITORY_OWNER, repository: "ContextualWisdomLab/.github", job_workflow_ref: configuredRef, + job_workflow_sha: configuredWorkflowSha, sub: "repo:ContextualWisdomLab/.github:ref:refs/heads/main", exp: now + 300, nbf: now - 30, @@ -316,4 +319,4 @@ describe("GitHub App runtime coverage through the public exchange boundary", () expect(second.status).toBe(200); expect(installationLookups).toBe(2); }); -}); \ No newline at end of file +}); diff --git a/test/oidc-jwks-cache-expiry.test.ts b/test/oidc-jwks-cache-expiry.test.ts index 73692a037..64697a6a5 100644 --- a/test/oidc-jwks-cache-expiry.test.ts +++ b/test/oidc-jwks-cache-expiry.test.ts @@ -3,6 +3,7 @@ import type { Env } from "../src/index"; const configuredWorkflowRef = "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main"; +const configuredWorkflowSha = "a".repeat(40); const env: Env = { ALLOWED_ISSUER: "https://token.actions.githubusercontent.com", @@ -10,6 +11,7 @@ const env: Env = { ALLOWED_REPOSITORY_OWNER: "ContextualWisdomLab", ALLOWED_WORKFLOW_REPOSITORY: "ContextualWisdomLab/.github", ALLOWED_WORKFLOW_REF_PREFIX: configuredWorkflowRef, + ALLOWED_WORKFLOW_SHA: configuredWorkflowSha, GITHUB_API_BASE: "https://api.github.com", GITHUB_APP_ID: "1", GITHUB_APP_PRIVATE_KEY_PEM: "unused-before-request-validation", @@ -44,6 +46,7 @@ async function createSignedJwt(nowEpochSeconds: number) { repository_owner: env.ALLOWED_REPOSITORY_OWNER, repository: "ContextualWisdomLab/.github", job_workflow_ref: configuredWorkflowRef, + job_workflow_sha: configuredWorkflowSha, sub: "repo:ContextualWisdomLab/.github:ref:refs/heads/main", exp: nowEpochSeconds + 300, nbf: nowEpochSeconds - 30, diff --git a/test/oidc-verification-residual-coverage.test.ts b/test/oidc-verification-residual-coverage.test.ts index 1bbc427fe..9587aea6a 100644 --- a/test/oidc-verification-residual-coverage.test.ts +++ b/test/oidc-verification-residual-coverage.test.ts @@ -3,6 +3,7 @@ import type { Env } from "../src/index"; const configuredWorkflowRef = "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main"; +const configuredWorkflowSha = "a".repeat(40); const trustedDiscoveryUrl = "https://token.actions.githubusercontent.com/.well-known/openid-configuration"; const trustedJwksUrl = "https://token.actions.githubusercontent.com/.well-known/jwks"; @@ -14,6 +15,7 @@ const env: Env = { ALLOWED_REPOSITORY_OWNER: "ContextualWisdomLab", ALLOWED_WORKFLOW_REPOSITORY: "ContextualWisdomLab/.github", ALLOWED_WORKFLOW_REF_PREFIX: configuredWorkflowRef, + ALLOWED_WORKFLOW_SHA: configuredWorkflowSha, GITHUB_API_BASE: "https://api.github.com", GITHUB_APP_ID: "1", GITHUB_APP_PRIVATE_KEY_PEM: "unused-before-request-validation", @@ -38,6 +40,7 @@ function baseClaims(now = Math.floor(Date.now() / 1000)): Record { const claims = baseClaims(); claims.aud = ["unrelated-audience", env.ALLOWED_AUDIENCE]; delete claims.job_workflow_ref; + delete claims.job_workflow_sha; claims.workflow_ref = configuredWorkflowRef; + claims.workflow_sha = configuredWorkflowSha; delete claims.nbf; const { response } = await exchange(await signedJwt(claims)); diff --git a/test/oidc-workflow-sha-authoritative-boundary.test.ts b/test/oidc-workflow-sha-authoritative-boundary.test.ts new file mode 100644 index 000000000..95dffae2c --- /dev/null +++ b/test/oidc-workflow-sha-authoritative-boundary.test.ts @@ -0,0 +1,160 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import type { Env } from "../src/entrypoint"; + +const configuredWorkflowRef = + "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main"; +const signingKid = "oidc-workflow-sha-authoritative-boundary"; +const trustedDiscoveryUrl = + "https://token.actions.githubusercontent.com/.well-known/openid-configuration"; +const trustedJwksUrl = "https://token.actions.githubusercontent.com/.well-known/jwks"; + +function allowingRateLimitNamespace(): DurableObjectNamespace { + return { + idFromName(name: string) { + return { toString: () => name } as DurableObjectId; + }, + get() { + return { + fetch: async () => new Response(JSON.stringify({ + allowed: true, + limit: 1000, + remaining: 999, + retry_after_seconds: 0, + }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + } as unknown as DurableObjectStub; + }, + } as unknown as DurableObjectNamespace; +} + +const envWithoutWorkflowSha: Env = { + ALLOWED_ISSUER: "https://token.actions.githubusercontent.com", + ALLOWED_AUDIENCE: "cwl-noema-review", + ALLOWED_REPOSITORY_OWNER: "ContextualWisdomLab", + ALLOWED_WORKFLOW_REPOSITORY: "ContextualWisdomLab/.github", + ALLOWED_WORKFLOW_REF_PREFIX: configuredWorkflowRef, + GITHUB_API_BASE: "https://api.github.com", + GITHUB_APP_ID: "1", + GITHUB_APP_PRIVATE_KEY_PEM: "unused-before-request-validation", + NOEMA_RATE_LIMIT_PER_MINUTE: "1000", + NOEMA_RATE_LIMITER: allowingRateLimitNamespace(), + NOEMA_OIDC_REPLAY_GUARD: {} as DurableObjectNamespace, +}; + +let signingPrivateKey: CryptoKey; +let signingPublicJwk: JsonWebKey; + +function encodeJson(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString("base64url"); +} + +function encodeBytes(value: Uint8Array): string { + return Buffer.from(value).toString("base64url"); +} + +async function signedJwt(payload: Record): Promise { + const encodedHeader = encodeJson({ alg: "RS256", kid: signingKid }); + const encodedPayload = encodeJson(payload); + const signature = new Uint8Array( + await crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + signingPrivateKey, + new TextEncoder().encode(`${encodedHeader}.${encodedPayload}`), + ), + ); + return `${encodedHeader}.${encodedPayload}.${encodeBytes(signature)}`; +} + +beforeAll(async () => { + const keyPair = (await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + )) as CryptoKeyPair; + signingPrivateKey = keyPair.privateKey; + signingPublicJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); +}); + +async function exchangeWithAuthoritativeConfig( + workflowSha: string | undefined, +): Promise { + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = String(input); + if (url === trustedDiscoveryUrl) { + return Response.json({ jwks_uri: trustedJwksUrl }); + } + if (url === trustedJwksUrl) { + return Response.json({ + keys: [{ ...signingPublicJwk, kid: signingKid, kty: "RSA" }], + }); + } + return new Response("unexpected privileged egress", { status: 500 }); + }); + + const now = Math.floor(Date.now() / 1000); + const token = await signedJwt({ + iss: envWithoutWorkflowSha.ALLOWED_ISSUER, + aud: envWithoutWorkflowSha.ALLOWED_AUDIENCE, + repository_owner: envWithoutWorkflowSha.ALLOWED_REPOSITORY_OWNER, + repository: "ContextualWisdomLab/.github", + sub: "repo:ContextualWisdomLab/.github:ref:refs/heads/main", + job_workflow_ref: configuredWorkflowRef, + job_workflow_sha: "a".repeat(40), + exp: now + 300, + nbf: now - 30, + iat: now - 30, + }); + + vi.resetModules(); + const { default: worker } = await import("../src/entrypoint"); + return worker.fetch( + new Request("https://noema.example/exchange", { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + "cf-connecting-ip": "203.0.113.124", + }, + body: JSON.stringify({ target_repository: { owner: "ContextualWisdomLab" } }), + }), + { ...envWithoutWorkflowSha, ALLOWED_WORKFLOW_SHA: workflowSha }, + ); +} + +async function expectWorkflowSourceConfigurationFailure( + workflowSha: string | undefined, +): Promise { + const response = await exchangeWithAuthoritativeConfig(workflowSha); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_WORKFLOW_NOT_ALLOWED", + message: "Workflow source trust configuration unavailable", + details: { + match_policy: "exact-ref-and-source-sha", + }, + }); +} + +describe("authoritative OIDC workflow source configuration", () => { + it("fails closed when the base exchange worker receives no immutable workflow source SHA", async () => { + await expectWorkflowSourceConfigurationFailure(undefined); + }); + + it("fails closed when the base exchange worker receives a non-canonical workflow source SHA", async () => { + await expectWorkflowSourceConfigurationFailure("A".repeat(40)); + }); +}); diff --git a/test/oidc-workflow-sha-binding.test.ts b/test/oidc-workflow-sha-binding.test.ts new file mode 100644 index 000000000..92759d3d3 --- /dev/null +++ b/test/oidc-workflow-sha-binding.test.ts @@ -0,0 +1,363 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import type { Env } from "../src/runtime-entrypoint"; + +const configuredWorkflowRef = + "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main"; +const configuredWorkflowSha = "a".repeat(40); +const signingKid = "oidc-workflow-sha-binding"; +const trustedDiscoveryUrl = + "https://token.actions.githubusercontent.com/.well-known/openid-configuration"; +const trustedJwksUrl = "https://token.actions.githubusercontent.com/.well-known/jwks"; + +function allowingRateLimitNamespace(): DurableObjectNamespace { + return { + idFromName(name: string) { + return { toString: () => name } as DurableObjectId; + }, + get() { + return { + fetch: async () => new Response(JSON.stringify({ + allowed: true, + limit: 1000, + remaining: 999, + retry_after_seconds: 0, + }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + } as unknown as DurableObjectStub; + }, + } as unknown as DurableObjectNamespace; +} + +const env: Env = { + ALLOWED_ISSUER: "https://token.actions.githubusercontent.com", + ALLOWED_AUDIENCE: "cwl-noema-review", + ALLOWED_REPOSITORY_OWNER: "ContextualWisdomLab", + ALLOWED_WORKFLOW_REPOSITORY: "ContextualWisdomLab/.github", + ALLOWED_WORKFLOW_REF_PREFIX: configuredWorkflowRef, + ALLOWED_WORKFLOW_SHA: configuredWorkflowSha, + GITHUB_API_BASE: "https://api.github.com", + GITHUB_APP_ID: "1", + GITHUB_APP_PRIVATE_KEY_PEM: "unused-before-request-validation", + NOEMA_RATE_LIMIT_PER_MINUTE: "1000", + NOEMA_RATE_LIMITER: allowingRateLimitNamespace(), + NOEMA_OIDC_REPLAY_GUARD: {} as DurableObjectNamespace, +}; + +let signingPrivateKey: CryptoKey; +let signingPublicJwk: JsonWebKey; + +function encodeJson(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString("base64url"); +} + +function encodeBytes(value: Uint8Array): string { + return Buffer.from(value).toString("base64url"); +} + +function unsignedJwt(payload: unknown): string { + return `${encodeJson({})}.${encodeJson(payload)}.eA`; +} + +async function signedJwt(payload: Record): Promise { + const encodedHeader = encodeJson({ alg: "RS256", kid: signingKid }); + const encodedPayload = encodeJson(payload); + const signature = new Uint8Array( + await crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + signingPrivateKey, + new TextEncoder().encode(`${encodedHeader}.${encodedPayload}`), + ), + ); + return `${encodedHeader}.${encodedPayload}.${encodeBytes(signature)}`; +} + +beforeAll(async () => { + const keyPair = (await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + )) as CryptoKeyPair; + signingPrivateKey = keyPair.privateKey; + signingPublicJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); +}); + +async function exchangeWithToken( + token: string, + overrides: Partial = {}, +): Promise { + vi.resetModules(); + const { default: worker } = await import("../src/runtime-entrypoint"); + return worker.fetch( + new Request("https://noema.example/exchange", { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + "cf-connecting-ip": "203.0.113.123", + }, + body: JSON.stringify({ target_repository: { owner: "ContextualWisdomLab" } }), + }), + { ...env, ...overrides }, + ); +} + +async function exchangeWithTrustedOidc(token: string): Promise { + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = String(input); + if (url === trustedDiscoveryUrl) { + return Response.json({ jwks_uri: trustedJwksUrl }); + } + if (url === trustedJwksUrl) { + return Response.json({ + keys: [{ ...signingPublicJwk, kid: signingKid, kty: "RSA" }], + }); + } + return new Response("unexpected privileged egress", { status: 500 }); + }); + return exchangeWithToken(token); +} + +async function exchangeWithClaims(claims: Record): Promise { + const now = Math.floor(Date.now() / 1000); + const token = await signedJwt({ + iss: env.ALLOWED_ISSUER, + aud: env.ALLOWED_AUDIENCE, + repository_owner: env.ALLOWED_REPOSITORY_OWNER, + repository: "ContextualWisdomLab/.github", + sub: "repo:ContextualWisdomLab/.github:ref:refs/heads/main", + exp: now + 300, + nbf: now - 30, + iat: now - 30, + ...claims, + }); + return exchangeWithToken(token); +} + +async function trustedExchangeWithClaims(claims: Record): Promise { + const now = Math.floor(Date.now() / 1000); + const token = await signedJwt({ + iss: env.ALLOWED_ISSUER, + aud: env.ALLOWED_AUDIENCE, + repository_owner: env.ALLOWED_REPOSITORY_OWNER, + repository: "ContextualWisdomLab/.github", + sub: "repo:ContextualWisdomLab/.github:ref:refs/heads/main", + exp: now + 300, + nbf: now - 30, + iat: now - 30, + ...claims, + }); + return exchangeWithTrustedOidc(token); +} + +async function exchangeWithWorkflowSha(jobWorkflowSha?: string): Promise { + return exchangeWithClaims({ + job_workflow_ref: configuredWorkflowRef, + ...(jobWorkflowSha === undefined ? {} : { job_workflow_sha: jobWorkflowSha }), + }); +} + +async function expectDelegatedMalformedToken( + token: string, + expectedStatus: 400 | 401, +): Promise { + const response = await exchangeWithToken(token); + expect(response.status).toBe(expectedStatus); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_TOKEN_MALFORMED", + }); +} + +describe("production OIDC reusable-workflow source identity", () => { + it("rejects a signed token whose job_workflow_sha differs from the configured immutable workflow source", async () => { + const response = await exchangeWithWorkflowSha("b".repeat(40)); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_WORKFLOW_NOT_ALLOWED", + message: "OIDC workflow source revision is not allowed", + details: { + match_policy: "exact-ref-and-source-sha", + }, + }); + }); + + it("rejects a signed reusable-workflow token that omits job_workflow_sha", async () => { + const response = await exchangeWithWorkflowSha(); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_WORKFLOW_NOT_ALLOWED", + message: "OIDC workflow source revision is not allowed", + details: { + match_policy: "exact-ref-and-source-sha", + }, + }); + }); + + it("binds the fallback workflow_ref identity to its workflow_sha instead of bypassing immutable source policy", async () => { + const response = await exchangeWithClaims({ + workflow_ref: configuredWorkflowRef, + workflow_sha: "b".repeat(40), + }); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_WORKFLOW_NOT_ALLOWED", + message: "OIDC workflow source revision is not allowed", + details: { + match_policy: "exact-ref-and-source-sha", + }, + }); + }); + + it.each([ + ["reusable", { job_workflow_ref: configuredWorkflowRef, job_workflow_sha: configuredWorkflowSha }], + ["direct", { workflow_ref: configuredWorkflowRef, workflow_sha: configuredWorkflowSha }], + ] as const)( + "accepts a correctly signed exact %s workflow source pair through the authoritative verifier", + async (_identityKind, workflowClaims) => { + const response = await trustedExchangeWithClaims(workflowClaims); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_VALIDATION_INPUT", + details: { + field: "target_repository", + }, + }); + }, + ); + + it.each([undefined, "", "A".repeat(40)])( + "fails closed when the immutable workflow source configuration is unusable (%s)", + async (configuredSha) => { + const response = await exchangeWithToken( + unsignedJwt({ + job_workflow_ref: configuredWorkflowRef, + job_workflow_sha: configuredWorkflowSha, + }), + { ALLOWED_WORKFLOW_SHA: configuredSha }, + ); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_WORKFLOW_NOT_ALLOWED", + message: "Workflow source trust configuration unavailable", + details: { + match_policy: "exact-ref-and-source-sha", + }, + }); + }, + ); + + it("allows an exact reusable-workflow source pair through to the authoritative verifier", async () => { + await expectDelegatedMalformedToken( + unsignedJwt({ + job_workflow_ref: configuredWorkflowRef, + job_workflow_sha: configuredWorkflowSha, + }), + 401, + ); + }); + + it("allows an exact fallback workflow source pair through to the authoritative verifier", async () => { + await expectDelegatedMalformedToken( + unsignedJwt({ + workflow_ref: configuredWorkflowRef, + workflow_sha: configuredWorkflowSha, + }), + 401, + ); + }); + + it("rejects an unrelated workflow identity at the denial-only exact-ref boundary", async () => { + const response = await exchangeWithToken( + unsignedJwt({ + job_workflow_ref: + "ContextualWisdomLab/.github/.github/workflows/other.yml@refs/heads/main", + job_workflow_sha: "b".repeat(40), + }), + ); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_WORKFLOW_NOT_ALLOWED", + message: "OIDC workflow_ref is not allowed", + details: { + match_policy: "exact", + hint: expect.stringContaining("prefix-sharing refs are rejected"), + }, + }); + }); + + it("leaves claims without a usable workflow ref to the authoritative verifier", async () => { + await expectDelegatedMalformedToken( + unsignedJwt({ + job_workflow_ref: 42, + workflow_ref: null, + job_workflow_sha: configuredWorkflowSha, + }), + 401, + ); + }); + + it("leaves decoded non-object claims to the bounded authoritative token parser", async () => { + for (const claims of [null, "not-an-object"] as const) { + const response = await exchangeWithToken(unsignedJwt(claims)); + expect([400, 401]).toContain(response.status); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_TOKEN_MALFORMED", + }); + } + }); + + it("delegates absent exact workflow-ref configuration to the hardened workflow-trust boundary", async () => { + const response = await exchangeWithToken( + unsignedJwt({ + job_workflow_ref: configuredWorkflowRef, + job_workflow_sha: configuredWorkflowSha, + }), + { ALLOWED_WORKFLOW_REF_PREFIX: undefined }, + ); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_WORKFLOW_NOT_ALLOWED", + message: "Workflow trust configuration unavailable", + details: { + match_policy: "exact", + }, + }); + }); + + it("leaves malformed decoded claims to the bounded authoritative token parser", async () => { + await expectDelegatedMalformedToken("e30.eA.eA", 400); + await expectDelegatedMalformedToken("e30.e30", 400); + await expectDelegatedMalformedToken(`e30.${encodeJson([])}.eA`, 401); + }); + + it("does not decode a source-policy payload above the bounded JWT payload limit", async () => { + await expectDelegatedMalformedToken(`e30.${"a".repeat(8_193)}.eA`, 400); + }); +}); diff --git a/test/oidc-workflow-sha-cryptographic.test.ts b/test/oidc-workflow-sha-cryptographic.test.ts new file mode 100644 index 000000000..7c4e24eca --- /dev/null +++ b/test/oidc-workflow-sha-cryptographic.test.ts @@ -0,0 +1,120 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import type { Env } from "../src/index"; + +const configuredWorkflowRef = + "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main"; +const configuredWorkflowSha = "a".repeat(40); +const trustedDiscoveryUrl = + "https://token.actions.githubusercontent.com/.well-known/openid-configuration"; +const trustedJwksUrl = "https://token.actions.githubusercontent.com/.well-known/jwks"; +const signingKid = "oidc-workflow-sha-cryptographic"; + +const env = { + ALLOWED_ISSUER: "https://token.actions.githubusercontent.com", + ALLOWED_AUDIENCE: "cwl-noema-review", + ALLOWED_REPOSITORY_OWNER: "ContextualWisdomLab", + ALLOWED_WORKFLOW_REPOSITORY: "ContextualWisdomLab/.github", + ALLOWED_WORKFLOW_REF_PREFIX: configuredWorkflowRef, + ALLOWED_WORKFLOW_SHA: configuredWorkflowSha, + GITHUB_API_BASE: "https://api.github.com", + GITHUB_APP_ID: "1", + GITHUB_APP_PRIVATE_KEY_PEM: "unused-before-request-validation", + NOEMA_RATE_LIMIT_PER_MINUTE: "1000", +} satisfies Env & { ALLOWED_WORKFLOW_SHA: string }; + +let signingPrivateKey: CryptoKey; +let signingPublicJwk: JsonWebKey; + +function encodeJson(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString("base64url"); +} + +function encodeBytes(value: Uint8Array): string { + return Buffer.from(value).toString("base64url"); +} + +async function signedJwt(payload: Record): Promise { + const encodedHeader = encodeJson({ alg: "RS256", kid: signingKid }); + const encodedPayload = encodeJson(payload); + const signature = new Uint8Array( + await crypto.subtle.sign( + "RSASSA-PKCS1-v1_5", + signingPrivateKey, + new TextEncoder().encode(`${encodedHeader}.${encodedPayload}`), + ), + ); + return `${encodedHeader}.${encodedPayload}.${encodeBytes(signature)}`; +} + +beforeAll(async () => { + const keyPair = (await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + )) as CryptoKeyPair; + signingPrivateKey = keyPair.privateKey; + signingPublicJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); +}); + +describe("cryptographic OIDC workflow source identity", () => { + it("rejects a signed reusable-workflow SHA mismatch at the authoritative verifier", async () => { + const now = Math.floor(Date.now() / 1000); + const token = await signedJwt({ + iss: env.ALLOWED_ISSUER, + aud: env.ALLOWED_AUDIENCE, + repository_owner: env.ALLOWED_REPOSITORY_OWNER, + repository: "ContextualWisdomLab/.github", + job_workflow_ref: configuredWorkflowRef, + job_workflow_sha: "b".repeat(40), + sub: "repo:ContextualWisdomLab/.github:ref:refs/heads/main", + exp: now + 300, + nbf: now - 30, + iat: now - 30, + }); + + vi.resetModules(); + const { default: worker } = await import("../src/index"); + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = String(input); + if (url === trustedDiscoveryUrl) { + return Response.json({ jwks_uri: trustedJwksUrl }); + } + if (url === trustedJwksUrl) { + return Response.json({ + keys: [{ ...signingPublicJwk, kid: signingKid, kty: "RSA" }], + }); + } + return new Response("unexpected privileged egress", { status: 500 }); + }); + + const response = await worker.fetch( + new Request("https://noema.example/exchange", { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + "cf-connecting-ip": "203.0.113.125", + }, + body: JSON.stringify({ target_repository: { owner: "ContextualWisdomLab" } }), + }), + env, + ); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_WORKFLOW_NOT_ALLOWED", + message: "OIDC workflow source revision is not allowed", + }); + }); +}); diff --git a/test/operational-helper-coverage.test.ts b/test/operational-helper-coverage.test.ts index 7dd975921..0fd4af007 100644 --- a/test/operational-helper-coverage.test.ts +++ b/test/operational-helper-coverage.test.ts @@ -1,12 +1,15 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import worker, { type Env } from "../src/index"; +const configuredWorkflowSha = "a".repeat(40); + const baseEnv: Env = { ALLOWED_ISSUER: "https://token.actions.githubusercontent.com", ALLOWED_AUDIENCE: "cwl-noema-review", ALLOWED_REPOSITORY_OWNER: "ContextualWisdomLab", ALLOWED_WORKFLOW_REPOSITORY: "ContextualWisdomLab/.github", ALLOWED_WORKFLOW_REF_PREFIX: "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main", + ALLOWED_WORKFLOW_SHA: configuredWorkflowSha, GITHUB_API_BASE: "https://api.github.com", GITHUB_APP_ID: "1", GITHUB_APP_PRIVATE_KEY_PEM: "unused", @@ -127,6 +130,7 @@ describe("operational helper coverage", () => { repository_owner: baseEnv.ALLOWED_REPOSITORY_OWNER, repository: "ContextualWisdomLab/.github", job_workflow_ref: "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main", + job_workflow_sha: configuredWorkflowSha, sub: "repo:ContextualWisdomLab/.github:ref:refs/heads/main", exp: now + 300, nbf: now - 30, diff --git a/test/replay-request-core-coverage.test.ts b/test/replay-request-core-coverage.test.ts index a9247d09d..77b64dd7f 100644 --- a/test/replay-request-core-coverage.test.ts +++ b/test/replay-request-core-coverage.test.ts @@ -15,6 +15,7 @@ import worker, { type Env } from "../src/index"; const configuredRef = "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main"; +const configuredWorkflowSha = "a".repeat(40); const baseEnv: Env = { ALLOWED_ISSUER: "https://token.actions.githubusercontent.com", @@ -22,6 +23,7 @@ const baseEnv: Env = { ALLOWED_REPOSITORY_OWNER: "ContextualWisdomLab", ALLOWED_WORKFLOW_REPOSITORY: "ContextualWisdomLab/.github", ALLOWED_WORKFLOW_REF_PREFIX: configuredRef, + ALLOWED_WORKFLOW_SHA: configuredWorkflowSha, GITHUB_API_BASE: "https://api.github.com", GITHUB_APP_ID: "1", GITHUB_APP_PRIVATE_KEY_PEM: "unused", @@ -84,6 +86,7 @@ async function validOidcToken(overrides: Record = {}) { repository_owner: baseEnv.ALLOWED_REPOSITORY_OWNER, repository: "ContextualWisdomLab/.github", job_workflow_ref: configuredRef, + job_workflow_sha: configuredWorkflowSha, sub: "repo:ContextualWisdomLab/.github:ref:refs/heads/main", jti: "replay-request-jti", exp: now + 300, diff --git a/test/replay-target-authorization-coverage.test.ts b/test/replay-target-authorization-coverage.test.ts index e17a1ff14..3139bb179 100644 --- a/test/replay-target-authorization-coverage.test.ts +++ b/test/replay-target-authorization-coverage.test.ts @@ -3,6 +3,7 @@ import worker, { type Env } from "../src/index"; const configuredRef = "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main"; +const configuredWorkflowSha = "a".repeat(40); const env: Env = { ALLOWED_ISSUER: "https://token.actions.githubusercontent.com", @@ -10,6 +11,7 @@ const env: Env = { ALLOWED_REPOSITORY_OWNER: "ContextualWisdomLab", ALLOWED_WORKFLOW_REPOSITORY: "ContextualWisdomLab/.github", ALLOWED_WORKFLOW_REF_PREFIX: configuredRef, + ALLOWED_WORKFLOW_SHA: configuredWorkflowSha, GITHUB_API_BASE: "https://api.github.com", GITHUB_APP_ID: "1", GITHUB_APP_PRIVATE_KEY_PEM: "unused-before-authorization", @@ -40,6 +42,7 @@ async function createToken(repository: string | undefined) { repository_owner: env.ALLOWED_REPOSITORY_OWNER, repository, job_workflow_ref: configuredRef, + job_workflow_sha: configuredWorkflowSha, exp: now + 300, nbf: now - 30, }); diff --git a/test/runtime-readiness-cache.test.ts b/test/runtime-readiness-cache.test.ts index f14136b87..8d7509113 100644 --- a/test/runtime-readiness-cache.test.ts +++ b/test/runtime-readiness-cache.test.ts @@ -39,6 +39,7 @@ async function readyEnvironment(): Promise { ALLOWED_WORKFLOW_REPOSITORY: "ContextualWisdomLab/.github", ALLOWED_WORKFLOW_REF_PREFIX: "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main", + ALLOWED_WORKFLOW_SHA: "0123456789abcdef0123456789abcdef01234567", GITHUB_API_BASE: "https://api.github.com", GITHUB_APP_ID: "123456", GITHUB_APP_PRIVATE_KEY_PEM: await privateKeyPem(), diff --git a/test/runtime-readiness-durable-bindings.test.ts b/test/runtime-readiness-durable-bindings.test.ts index 93e14ee89..a17e6a843 100644 --- a/test/runtime-readiness-durable-bindings.test.ts +++ b/test/runtime-readiness-durable-bindings.test.ts @@ -56,6 +56,7 @@ async function readyEnvironment(): Promise { ALLOWED_WORKFLOW_REPOSITORY: "ContextualWisdomLab/.github", ALLOWED_WORKFLOW_REF_PREFIX: "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main", + ALLOWED_WORKFLOW_SHA: "a".repeat(40), GITHUB_API_BASE: "https://api.github.com", GITHUB_APP_ID: "123456", GITHUB_APP_PRIVATE_KEY_PEM: await privateKeyPem(), diff --git a/test/runtime-readiness-missing-config.test.ts b/test/runtime-readiness-missing-config.test.ts index 10fa7d811..aece7afd8 100644 --- a/test/runtime-readiness-missing-config.test.ts +++ b/test/runtime-readiness-missing-config.test.ts @@ -21,6 +21,7 @@ describe("Noema readiness with absent configuration", () => { "allowed_repository_owner", "allowed_workflow_repository", "allowed_workflow_ref", + "allowed_workflow_sha", "github_api_base", "github_app_id", "github_app_private_key", diff --git a/test/runtime-readiness-ref-format.test.ts b/test/runtime-readiness-ref-format.test.ts index cbb3d1a0b..9e2d3116a 100644 --- a/test/runtime-readiness-ref-format.test.ts +++ b/test/runtime-readiness-ref-format.test.ts @@ -42,6 +42,7 @@ async function readyEnvironment(): Promise { ALLOWED_WORKFLOW_REPOSITORY: "ContextualWisdomLab/.github", ALLOWED_WORKFLOW_REF_PREFIX: "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main", + ALLOWED_WORKFLOW_SHA: "a".repeat(40), GITHUB_API_BASE: "https://api.github.com", GITHUB_APP_ID: "123456", GITHUB_APP_PRIVATE_KEY_PEM: await privateKeyPem(), diff --git a/test/runtime-readiness.test.ts b/test/runtime-readiness.test.ts index 454ce51f6..b3292d6b1 100644 --- a/test/runtime-readiness.test.ts +++ b/test/runtime-readiness.test.ts @@ -39,6 +39,7 @@ async function readyEnv(): Promise { ALLOWED_WORKFLOW_REPOSITORY: "ContextualWisdomLab/.github", ALLOWED_WORKFLOW_REF_PREFIX: "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main", + ALLOWED_WORKFLOW_SHA: "0123456789abcdef0123456789abcdef01234567", GITHUB_API_BASE: "https://api.github.com", GITHUB_APP_ID: "123456", GITHUB_APP_PRIVATE_KEY_PEM: await privateKeyPem(), @@ -130,6 +131,12 @@ describe("Noema runtime readiness", () => { "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/*", "allowed_workflow_ref", ], + [ + "allowed_workflow_sha", + "ALLOWED_WORKFLOW_SHA", + "ABCDEF0123456789ABCDEF0123456789ABCDEF01", + "allowed_workflow_sha", + ], [ "github_api_base", "GITHUB_API_BASE", diff --git a/test/runtime-workflow-prefilter-coverage.test.ts b/test/runtime-workflow-prefilter-coverage.test.ts new file mode 100644 index 000000000..68704ee2a --- /dev/null +++ b/test/runtime-workflow-prefilter-coverage.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import worker, { type Env } from "../src/runtime-entrypoint"; + +function allowingRateLimiter(): DurableObjectNamespace { + return { + idFromName(name: string) { + return { toString: () => name } as DurableObjectId; + }, + get() { + return { + async fetch() { + return Response.json({ + allowed: true, + limit: 1000, + remaining: 999, + retry_after_seconds: 0, + }); + }, + } as unknown as DurableObjectStub; + }, + } as unknown as DurableObjectNamespace; +} + +const env: Env = { + ALLOWED_ISSUER: "https://token.actions.githubusercontent.com", + ALLOWED_AUDIENCE: "cwl-noema-review", + ALLOWED_REPOSITORY_OWNER: "ContextualWisdomLab", + ALLOWED_WORKFLOW_REPOSITORY: "ContextualWisdomLab/.github", + ALLOWED_WORKFLOW_REF_PREFIX: + "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main", + ALLOWED_WORKFLOW_SHA: "a".repeat(40), + GITHUB_API_BASE: "https://api.github.com", + GITHUB_APP_ID: "1", + GITHUB_APP_PRIVATE_KEY_PEM: "unused-before-authentication", + NOEMA_RATE_LIMIT_PER_MINUTE: "1000", + NOEMA_RATE_LIMITER: allowingRateLimiter(), +}; + +async function expectMissingAuth(headers: Record = {}): Promise { + const response = await worker.fetch( + new Request("https://noema.example/exchange", { + method: "POST", + headers: { + "cf-connecting-ip": "203.0.113.126", + ...headers, + }, + }), + env, + ); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error_code: "ERR_AUTH_MISSING", + }); + expect(response.headers.get("www-authenticate")).toBe( + 'Bearer realm="noema", error="invalid_request"', + ); +} + +describe("runtime workflow-source prefilter coverage", () => { + it("delegates an exchange request without bearer credentials to the authoritative auth boundary", async () => { + await expectMissingAuth(); + }); + + it("does not treat whitespace-only bearer credentials as a source-policy JWT", async () => { + await expectMissingAuth({ authorization: "Bearer " }); + }); +}); diff --git a/test/worker.test.ts b/test/worker.test.ts index be9083346..b605d8f98 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -7,6 +7,7 @@ const env: Env = { ALLOWED_REPOSITORY_OWNER: "ContextualWisdomLab", ALLOWED_WORKFLOW_REPOSITORY: "ContextualWisdomLab/.github", ALLOWED_WORKFLOW_REF_PREFIX: "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main", + ALLOWED_WORKFLOW_SHA: "a".repeat(40), GITHUB_API_BASE: "https://api.github.com", GITHUB_APP_ID: "1", GITHUB_APP_PRIVATE_KEY_PEM: "unused", @@ -221,6 +222,7 @@ describe("Noema worker", () => { repository_owner: env.ALLOWED_REPOSITORY_OWNER, repository: "ContextualWisdomLab/.github", job_workflow_ref: "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main", + job_workflow_sha: env.ALLOWED_WORKFLOW_SHA, sub: "repo:ContextualWisdomLab/.github:ref:refs/heads/main", exp: now + 300, nbf: now - 30, @@ -271,6 +273,7 @@ describe("Noema worker", () => { repository_owner: env.ALLOWED_REPOSITORY_OWNER, repository: "ContextualWisdomLab/.github", job_workflow_ref: "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main", + job_workflow_sha: env.ALLOWED_WORKFLOW_SHA, sub: "repo:ContextualWisdomLab/.github:ref:refs/heads/main", exp: now + 300, nbf: now - 30, @@ -358,6 +361,7 @@ describe("Noema worker", () => { repository_owner: env.ALLOWED_REPOSITORY_OWNER, repository: "ContextualWisdomLab/.github", job_workflow_ref: "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main", + job_workflow_sha: env.ALLOWED_WORKFLOW_SHA, sub: "repo:ContextualWisdomLab/.github:ref:refs/heads/main", exp: now + 300, nbf: now - 30, @@ -450,6 +454,7 @@ describe("Noema worker", () => { repository_owner: env.ALLOWED_REPOSITORY_OWNER, repository: "ContextualWisdomLab/.github", job_workflow_ref: "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main", + job_workflow_sha: env.ALLOWED_WORKFLOW_SHA, sub: "repo:ContextualWisdomLab/.github:ref:refs/heads/main", exp: now + 300, nbf: now - 30, diff --git a/wrangler.toml b/wrangler.toml index f2cc3dc20..bf8942e02 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -24,6 +24,7 @@ ALLOWED_AUDIENCE = "cwl-noema-review" ALLOWED_REPOSITORY_OWNER = "ContextualWisdomLab" ALLOWED_WORKFLOW_REPOSITORY = "ContextualWisdomLab/.github" ALLOWED_WORKFLOW_REF_PREFIX = "ContextualWisdomLab/.github/.github/workflows/noema-review.yml@refs/heads/main" +ALLOWED_WORKFLOW_SHA = "f16280a0aa215563d29200c5d0bab75c48af614a" GITHUB_API_BASE = "https://api.github.com" GITHUB_APP_SLUG = "noema" NOEMA_RATE_LIMIT_PER_MINUTE = "60" @@ -34,4 +35,4 @@ NOEMA_INSTALLATION_CACHE_TTL_SECONDS = "600" # wrangler secret put GITHUB_APP_ID # wrangler secret put GITHUB_APP_PRIVATE_KEY_PEM # Optional: restrict to a specific installation id instead of discovering by repo. -# wrangler secret put GITHUB_APP_INSTALLATION_ID +# wrangler secret put GITHUB_APP_INSTALLATION_ID \ No newline at end of file