From 2ca196a5520d369d7e8450488db921f56d9fa6ec Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 19:30:14 +0900 Subject: [PATCH 01/14] test(agent-runtime): require signed procedural evaluation handoff --- test/procedural-evaluation-handoff.test.mjs | 241 ++++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 test/procedural-evaluation-handoff.test.mjs diff --git a/test/procedural-evaluation-handoff.test.mjs b/test/procedural-evaluation-handoff.test.mjs new file mode 100644 index 000000000..4d88ce422 --- /dev/null +++ b/test/procedural-evaluation-handoff.test.mjs @@ -0,0 +1,241 @@ +import { test } from "vitest"; +import assert from "node:assert/strict"; +import { createProceduralGraph } from "../src/agent-runtime/procedural-graph.ts"; +import { assessProceduralCandidate } from "../src/agent-runtime/procedural-evolution.ts"; +import { + admitProceduralEvaluationEvidence, + proceduralEvaluationEvidenceDigest, +} from "../src/agent-runtime/procedural-evaluation-authority.ts"; +import { + assertAuthenticatedProceduralEvaluationEvidence, + verifyProceduralEvaluationHandoff, +} from "../src/agent-runtime/procedural-evaluation-handoff.ts"; + +const digest = (character) => character.repeat(64); + +async function evaluationEvidence() { + const baseline = await createProceduralGraph({ + schemaVersion: "noema.procedural-graph/v1", + tenantId: "tenant-a", + taskType: "repair", + graphId: "graph-a", + revision: 1, + parentDigest: null, + nodes: ["Start", "check"], + edges: [{ + from: "Start", + relation: "requires", + to: "check", + condition: "", + guidance: "Check evidence", + pitfalls: "", + }], + }); + const candidate = await createProceduralGraph({ + schemaVersion: "noema.procedural-graph/v1", + tenantId: "tenant-a", + taskType: "repair", + graphId: "graph-a", + revision: 2, + parentDigest: baseline.digest, + nodes: ["Start", "check"], + edges: [{ + from: "Start", + relation: "requires", + to: "check", + condition: "", + guidance: "Check exact-head evidence", + pitfalls: "", + }], + }); + const contextDigest = digest("c"); + const decision = await assessProceduralCandidate({ + baseline, + candidate, + plan: { + contextDigest, + minimumCases: 2, + trainingCaseIds: ["train-1"], + holdoutCaseIds: ["case-1", "case-2"], + }, + baselineReceipt: { + graphDigest: baseline.digest, + contextDigest, + observations: [ + { caseId: "case-1", score: 0.5, safetyViolations: 0 }, + { caseId: "case-2", score: 0.6, safetyViolations: 0 }, + ], + }, + candidateReceipt: { + graphDigest: candidate.digest, + contextDigest, + observations: [ + { caseId: "case-1", score: 0.7, safetyViolations: 0 }, + { caseId: "case-2", score: 0.8, safetyViolations: 0 }, + ], + }, + rejectedKeys: [], + }); + const metadata = { + schemaVersion: "noema.procedural-evaluation-authority/v1", + evaluatorId: "evaluation-owner", + evaluatorVersion: "eval-v1", + policyVersion: "policy-v1", + datasetDigest: digest("1"), + rubricDigest: digest("2"), + modelIdentityDigest: digest("3"), + toolIdentityDigest: digest("4"), + protocolDigest: digest("5"), + validationPlanDigest: digest("6"), + }; + const expected = await proceduralEvaluationEvidenceDigest(decision, metadata); + return admitProceduralEvaluationEvidence(decision, metadata, expected); +} + +async function keyPair() { + return crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"], + ); +} + +function canonicalMessage(envelopeDigest, signerKeyId, issuedAtEpochSeconds, expiresAtEpochSeconds) { + return new TextEncoder().encode(JSON.stringify([ + "noema.procedural-evaluation-handoff/v1", + envelopeDigest, + signerKeyId, + issuedAtEpochSeconds, + expiresAtEpochSeconds, + ])); +} + +function base64Url(bytes) { + return Buffer.from(bytes).toString("base64url"); +} + +async function signedHandoff(evidence, signerKeyId, privateKey, overrides = {}) { + const now = Math.floor(Date.now() / 1000); + const issuedAtEpochSeconds = overrides.issuedAtEpochSeconds ?? now - 1; + const expiresAtEpochSeconds = overrides.expiresAtEpochSeconds ?? now + 120; + const envelopeDigest = overrides.envelopeDigest ?? evidence.envelopeDigest; + const signature = await crypto.subtle.sign( + { name: "ECDSA", hash: "SHA-256" }, + privateKey, + canonicalMessage(envelopeDigest, signerKeyId, issuedAtEpochSeconds, expiresAtEpochSeconds), + ); + return { + schemaVersion: "noema.procedural-evaluation-handoff/v1", + envelopeDigest, + signerKeyId, + issuedAtEpochSeconds, + expiresAtEpochSeconds, + signature: base64Url(new Uint8Array(signature)), + }; +} + +test("accepts only a signature from the separately trusted evaluator key", async () => { + const evidence = await evaluationEvidence(); + const keys = await keyPair(); + const handoff = await signedHandoff(evidence, "keyverse:evaluator/procedural-v1", keys.privateKey); + + const authenticated = await verifyProceduralEvaluationHandoff(evidence, handoff, { + signerKeyId: "keyverse:evaluator/procedural-v1", + verificationKey: keys.publicKey, + }); + + assert.equal(authenticated.evidence, evidence); + assert.equal(authenticated.signerKeyId, "keyverse:evaluator/procedural-v1"); + assert.match(authenticated.handoffDigest, /^[0-9a-f]{64}$/); + assert.equal(authenticated.activationAuthorized, false); + assert.doesNotThrow(() => assertAuthenticatedProceduralEvaluationEvidence(authenticated)); +}); + +test("a locally recomputed envelope digest cannot substitute for the trusted signer", async () => { + const evidence = await evaluationEvidence(); + const trusted = await keyPair(); + const untrusted = await keyPair(); + const handoff = await signedHandoff(evidence, "keyverse:evaluator/procedural-v1", untrusted.privateKey); + + await assert.rejects( + verifyProceduralEvaluationHandoff(evidence, handoff, { + signerKeyId: "keyverse:evaluator/procedural-v1", + verificationKey: trusted.publicKey, + }), + /evaluation_handoff_signature_invalid/, + ); +}); + +test("rejects a signer identity that does not match the trusted Keyverse-selected key id", async () => { + const evidence = await evaluationEvidence(); + const keys = await keyPair(); + const handoff = await signedHandoff(evidence, "keyverse:evaluator/other", keys.privateKey); + + await assert.rejects( + verifyProceduralEvaluationHandoff(evidence, handoff, { + signerKeyId: "keyverse:evaluator/procedural-v1", + verificationKey: keys.publicKey, + }), + /evaluation_handoff_signer_mismatch/, + ); +}); + +test("rejects a valid signature if it binds another evaluation envelope", async () => { + const evidence = await evaluationEvidence(); + const keys = await keyPair(); + const handoff = await signedHandoff(evidence, "keyverse:evaluator/procedural-v1", keys.privateKey, { + envelopeDigest: digest("f"), + }); + + await assert.rejects( + verifyProceduralEvaluationHandoff(evidence, handoff, { + signerKeyId: "keyverse:evaluator/procedural-v1", + verificationKey: keys.publicKey, + }), + /evaluation_handoff_envelope_mismatch/, + ); +}); + +test("rejects expired or excessively future-dated handoffs", async () => { + const evidence = await evaluationEvidence(); + const keys = await keyPair(); + const now = Math.floor(Date.now() / 1000); + const expired = await signedHandoff(evidence, "keyverse:evaluator/procedural-v1", keys.privateKey, { + issuedAtEpochSeconds: now - 120, + expiresAtEpochSeconds: now - 1, + }); + const future = await signedHandoff(evidence, "keyverse:evaluator/procedural-v1", keys.privateKey, { + issuedAtEpochSeconds: now + 120, + expiresAtEpochSeconds: now + 240, + }); + + await assert.rejects( + verifyProceduralEvaluationHandoff(evidence, expired, { + signerKeyId: "keyverse:evaluator/procedural-v1", + verificationKey: keys.publicKey, + }), + /evaluation_handoff_expired/, + ); + await assert.rejects( + verifyProceduralEvaluationHandoff(evidence, future, { + signerKeyId: "keyverse:evaluator/procedural-v1", + verificationKey: keys.publicKey, + }), + /evaluation_handoff_time_invalid/, + ); +}); + +test("structural copies of authenticated handoff evidence do not retain process-local authority", async () => { + const evidence = await evaluationEvidence(); + const keys = await keyPair(); + const handoff = await signedHandoff(evidence, "keyverse:evaluator/procedural-v1", keys.privateKey); + const authenticated = await verifyProceduralEvaluationHandoff(evidence, handoff, { + signerKeyId: "keyverse:evaluator/procedural-v1", + verificationKey: keys.publicKey, + }); + + assert.throws( + () => assertAuthenticatedProceduralEvaluationEvidence({ ...authenticated }), + /unadmitted_authenticated_evaluation/, + ); +}); \ No newline at end of file From 6900cb27c5c0dddbdebd311951312ece5d2f9953 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 19:33:59 +0900 Subject: [PATCH 02/14] feat(agent-runtime): verify procedural evaluation handoff signature --- .../procedural-evaluation-handoff.ts | 220 ++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 src/agent-runtime/procedural-evaluation-handoff.ts diff --git a/src/agent-runtime/procedural-evaluation-handoff.ts b/src/agent-runtime/procedural-evaluation-handoff.ts new file mode 100644 index 000000000..627c76c4e --- /dev/null +++ b/src/agent-runtime/procedural-evaluation-handoff.ts @@ -0,0 +1,220 @@ +import { + assertProceduralEvaluationEvidence, + type ProceduralEvaluationEvidence, +} from "./procedural-evaluation-authority"; +import { + normalizeProceduralError, + proceduralDigest, + proceduralHash, + proceduralIdentity, + proceduralInteger, + readProceduralRecord, + rejectProceduralInput, +} from "./procedural-input"; + +const HANDOFF_SCHEMA_VERSION = "noema.procedural-evaluation-handoff/v1" as const; +const MAX_HANDOFF_LIFETIME_SECONDS = 300; +const MAX_FUTURE_CLOCK_SKEW_SECONDS = 30; +const MAX_EPOCH_SECONDS = 4_102_444_800; +const ECDSA_P256_SIGNATURE_BYTES = 64; +const ECDSA_P256_SIGNATURE_BASE64URL_LENGTH = 86; + +/** + * Signed evaluator handoff that binds one exact procedural evaluation envelope to a bounded validity + * interval and an opaque signer key identity. The signature is verification evidence, not key custody. + */ +export interface ProceduralEvaluationHandoff { + readonly schemaVersion: typeof HANDOFF_SCHEMA_VERSION; + readonly envelopeDigest: string; + readonly signerKeyId: string; + readonly issuedAtEpochSeconds: number; + readonly expiresAtEpochSeconds: number; + readonly signature: string; +} + +/** + * Trusted verification input selected by the composition root from its external identity/key authority; + * Agent Runtime consumes this public key but does not discover, rotate, store, or administer signer keys. + */ +export interface ProceduralEvaluationHandoffTrust { + readonly signerKeyId: string; + readonly verificationKey: CryptoKey; +} + +/** + * Process-local authority created only after the exact evaluation envelope is verified against the + * separately trusted signer key and bounded handoff interval. It never grants policy or activation. + */ +export interface AuthenticatedProceduralEvaluationEvidence { + readonly evidence: ProceduralEvaluationEvidence; + readonly signerKeyId: string; + readonly issuedAtEpochSeconds: number; + readonly expiresAtEpochSeconds: number; + readonly handoffDigest: string; + readonly activationAuthorized: false; +} + +const admittedAuthenticatedEvidence = new WeakSet(); + +function parseSignature(value: unknown): Uint8Array { + if ( + typeof value !== "string" + || value.length !== ECDSA_P256_SIGNATURE_BASE64URL_LENGTH + || !/^[A-Za-z0-9_-]+$/.test(value) + ) { + rejectProceduralInput("evaluation_handoff_signature_invalid"); + } + try { + const base64 = value.replace(/-/g, "+").replace(/_/g, "/") + "=="; + const decoded = atob(base64); + if (decoded.length !== ECDSA_P256_SIGNATURE_BYTES) { + rejectProceduralInput("evaluation_handoff_signature_invalid"); + } + return Uint8Array.from(decoded, character => character.charCodeAt(0)); + } catch { + rejectProceduralInput("evaluation_handoff_signature_invalid"); + } +} + +function parseHandoff(input: unknown): { + handoff: ProceduralEvaluationHandoff; + signatureBytes: Uint8Array; +} { + const value = readProceduralRecord(input, [ + "schemaVersion", + "envelopeDigest", + "signerKeyId", + "issuedAtEpochSeconds", + "expiresAtEpochSeconds", + "signature", + ]); + if (value.schemaVersion !== HANDOFF_SCHEMA_VERSION) rejectProceduralInput("unsupported_schema"); + const signatureBytes = parseSignature(value.signature); + return { + handoff: Object.freeze({ + schemaVersion: HANDOFF_SCHEMA_VERSION, + envelopeDigest: proceduralDigest(value.envelopeDigest), + signerKeyId: proceduralIdentity(value.signerKeyId), + issuedAtEpochSeconds: proceduralInteger(value.issuedAtEpochSeconds, 0, MAX_EPOCH_SECONDS), + expiresAtEpochSeconds: proceduralInteger(value.expiresAtEpochSeconds, 0, MAX_EPOCH_SECONDS), + signature: value.signature as string, + }), + signatureBytes, + }; +} + +function parseTrust(input: unknown): ProceduralEvaluationHandoffTrust { + const value = readProceduralRecord(input, ["signerKeyId", "verificationKey"]); + if (value.verificationKey === null || typeof value.verificationKey !== "object") { + rejectProceduralInput("evaluation_handoff_signature_invalid"); + } + return Object.freeze({ + signerKeyId: proceduralIdentity(value.signerKeyId), + verificationKey: value.verificationKey as CryptoKey, + }); +} + +function signedMessage(handoff: ProceduralEvaluationHandoff): Uint8Array { + return new TextEncoder().encode(JSON.stringify([ + HANDOFF_SCHEMA_VERSION, + handoff.envelopeDigest, + handoff.signerKeyId, + handoff.issuedAtEpochSeconds, + handoff.expiresAtEpochSeconds, + ])); +} + +function assertHandoffTime(handoff: ProceduralEvaluationHandoff, nowEpochSeconds: number): void { + if (handoff.expiresAtEpochSeconds <= nowEpochSeconds) { + rejectProceduralInput("evaluation_handoff_expired"); + } + if ( + handoff.issuedAtEpochSeconds > nowEpochSeconds + MAX_FUTURE_CLOCK_SKEW_SECONDS + || handoff.expiresAtEpochSeconds <= handoff.issuedAtEpochSeconds + || handoff.expiresAtEpochSeconds - handoff.issuedAtEpochSeconds > MAX_HANDOFF_LIFETIME_SECONDS + ) { + rejectProceduralInput("evaluation_handoff_time_invalid"); + } +} + +async function verifySignature( + verificationKey: CryptoKey, + signatureBytes: Uint8Array, + message: Uint8Array, +): Promise { + let verified = false; + try { + verified = await crypto.subtle.verify( + { name: "ECDSA", hash: "SHA-256" }, + verificationKey, + signatureBytes, + message, + ); + } catch { + rejectProceduralInput("evaluation_handoff_signature_invalid"); + } + if (!verified) rejectProceduralInput("evaluation_handoff_signature_invalid"); +} + +/** + * Verifies an exact signed evaluator handoff against a public verification key already selected by the + * trusted composition root. This function does not resolve Keyverse identity, fetch credentials, persist + * evidence, approve a candidate, publish a contract, or authorize activation. + * @param evidence Locally admitted evaluation envelope whose exact digest must be signed by the evaluator. + * @param handoffInput Exact-key signed handoff carrying signer identity, bounded time interval, and signature. + * @param trustInput Separately trusted signer key id and public CryptoKey supplied by the composition root. + * @returns Frozen process-local authenticated evidence that remains explicitly unauthorized for activation. + */ +export async function verifyProceduralEvaluationHandoff( + evidence: ProceduralEvaluationEvidence, + handoffInput: unknown, + trustInput: unknown, +): Promise { + try { + assertProceduralEvaluationEvidence(evidence); + const { handoff, signatureBytes } = parseHandoff(handoffInput); + const trust = parseTrust(trustInput); + if (handoff.signerKeyId !== trust.signerKeyId) { + rejectProceduralInput("evaluation_handoff_signer_mismatch"); + } + if (handoff.envelopeDigest !== evidence.envelopeDigest) { + rejectProceduralInput("evaluation_handoff_envelope_mismatch"); + } + const nowEpochSeconds = Math.floor(Date.now() / 1000); + assertHandoffTime(handoff, nowEpochSeconds); + await verifySignature(trust.verificationKey, signatureBytes, signedMessage(handoff)); + const authority = Object.freeze({ + evidence, + signerKeyId: handoff.signerKeyId, + issuedAtEpochSeconds: handoff.issuedAtEpochSeconds, + expiresAtEpochSeconds: handoff.expiresAtEpochSeconds, + handoffDigest: await proceduralHash([ + HANDOFF_SCHEMA_VERSION, + handoff.envelopeDigest, + handoff.signerKeyId, + handoff.issuedAtEpochSeconds, + handoff.expiresAtEpochSeconds, + handoff.signature, + ]), + activationAuthorized: false as const, + }); + admittedAuthenticatedEvidence.add(authority); + return authority; + } catch (error) { + return normalizeProceduralError(error); + } +} + +/** + * Requires process-local provenance from the signed handoff verifier so structural copies and serialized + * lookalikes cannot be mistaken for authenticated evaluator evidence by State / Checkpoint or approval ports. + * @param value Unknown value proposed as authenticated procedural evaluation evidence. + * @returns Returns normally only for this module's admitted object; otherwise throws a closed procedural error. + */ +export function assertAuthenticatedProceduralEvaluationEvidence( + value: unknown, +): asserts value is AuthenticatedProceduralEvaluationEvidence { + if (value === null || typeof value !== "object" || !admittedAuthenticatedEvidence.has(value)) { + rejectProceduralInput("unadmitted_authenticated_evaluation"); + } +} \ No newline at end of file From a9b5cd0df98adf2daf3c6ccb269eda3e88d373fa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 19:34:26 +0900 Subject: [PATCH 03/14] fix(agent-runtime): close evaluation handoff error taxonomy --- src/agent-runtime/procedural-input.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/agent-runtime/procedural-input.ts b/src/agent-runtime/procedural-input.ts index c8217b7a4..7747c456c 100644 --- a/src/agent-runtime/procedural-input.ts +++ b/src/agent-runtime/procedural-input.ts @@ -13,7 +13,13 @@ const PROCEDURAL_ERROR_CODES = [ "unadmitted_session", "unadmitted_decision", "unadmitted_evaluation_evidence", + "unadmitted_authenticated_evaluation", "evaluation_evidence_digest_mismatch", + "evaluation_handoff_signer_mismatch", + "evaluation_handoff_envelope_mismatch", + "evaluation_handoff_time_invalid", + "evaluation_handoff_expired", + "evaluation_handoff_signature_invalid", "unsupported_schema", "duplicate_node", "missing_start", @@ -171,4 +177,4 @@ export async function proceduralHash(value: unknown): Promise { if (bytes.length > 1_048_576) rejectProceduralInput("graph_budget_exceeded"); const digest = await crypto.subtle.digest("SHA-256", bytes); return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, "0")).join(""); -} +} \ No newline at end of file From 3afeef2c1e9f9752555a2b381f10787401e59b62 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 20:06:25 +0900 Subject: [PATCH 04/14] test(agent-runtime): expire authenticated evaluator handoffs --- test/procedural-evaluation-handoff.test.mjs | 28 +++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/test/procedural-evaluation-handoff.test.mjs b/test/procedural-evaluation-handoff.test.mjs index 4d88ce422..7566935f7 100644 --- a/test/procedural-evaluation-handoff.test.mjs +++ b/test/procedural-evaluation-handoff.test.mjs @@ -1,4 +1,4 @@ -import { test } from "vitest"; +import { test, vi } from "vitest"; import assert from "node:assert/strict"; import { createProceduralGraph } from "../src/agent-runtime/procedural-graph.ts"; import { assessProceduralCandidate } from "../src/agent-runtime/procedural-evolution.ts"; @@ -238,4 +238,28 @@ test("structural copies of authenticated handoff evidence do not retain process- () => assertAuthenticatedProceduralEvaluationEvidence({ ...authenticated }), /unadmitted_authenticated_evaluation/, ); -}); \ No newline at end of file +}); + +test("authenticated handoff evidence stops being authority when the signed interval expires", async () => { + const evidence = await evaluationEvidence(); + const keys = await keyPair(); + const now = Math.floor(Date.now() / 1000); + const handoff = await signedHandoff(evidence, "keyverse:evaluator/procedural-v1", keys.privateKey, { + issuedAtEpochSeconds: now - 1, + expiresAtEpochSeconds: now + 1, + }); + const authenticated = await verifyProceduralEvaluationHandoff(evidence, handoff, { + signerKeyId: "keyverse:evaluator/procedural-v1", + verificationKey: keys.publicKey, + }); + + const clock = vi.spyOn(Date, "now").mockReturnValue((now + 2) * 1000); + try { + assert.throws( + () => assertAuthenticatedProceduralEvaluationEvidence(authenticated), + /evaluation_handoff_expired/, + ); + } finally { + clock.mockRestore(); + } +}); From ac670ace0e01964a67358495f67b4c7d9a7c8316 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 20:09:38 +0900 Subject: [PATCH 05/14] fix(agent-runtime): enforce evaluator handoff expiry at consumption --- src/agent-runtime/procedural-evaluation-handoff.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/agent-runtime/procedural-evaluation-handoff.ts b/src/agent-runtime/procedural-evaluation-handoff.ts index 627c76c4e..aa8bd5884 100644 --- a/src/agent-runtime/procedural-evaluation-handoff.ts +++ b/src/agent-runtime/procedural-evaluation-handoff.ts @@ -206,10 +206,10 @@ export async function verifyProceduralEvaluationHandoff( } /** - * Requires process-local provenance from the signed handoff verifier so structural copies and serialized - * lookalikes cannot be mistaken for authenticated evaluator evidence by State / Checkpoint or approval ports. + * Requires process-local provenance and a still-current signed validity interval so structural copies, + * serialized lookalikes, and expired handoffs cannot be mistaken for authenticated evaluator evidence. * @param value Unknown value proposed as authenticated procedural evaluation evidence. - * @returns Returns normally only for this module's admitted object; otherwise throws a closed procedural error. + * @returns Returns normally only for this module's admitted unexpired object; otherwise throws a closed error. */ export function assertAuthenticatedProceduralEvaluationEvidence( value: unknown, @@ -217,4 +217,8 @@ export function assertAuthenticatedProceduralEvaluationEvidence( if (value === null || typeof value !== "object" || !admittedAuthenticatedEvidence.has(value)) { rejectProceduralInput("unadmitted_authenticated_evaluation"); } -} \ No newline at end of file + const authenticated = value as AuthenticatedProceduralEvaluationEvidence; + if (authenticated.expiresAtEpochSeconds <= Math.floor(Date.now() / 1000)) { + rejectProceduralInput("evaluation_handoff_expired"); + } +} From c91f707735f491d1891eb2af5ad1e0d4c66b4096 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 20:12:10 +0900 Subject: [PATCH 06/14] test(agent-runtime): close signed handoff edge coverage --- test/procedural-evaluation-handoff.test.mjs | 116 ++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/test/procedural-evaluation-handoff.test.mjs b/test/procedural-evaluation-handoff.test.mjs index 7566935f7..56d150df0 100644 --- a/test/procedural-evaluation-handoff.test.mjs +++ b/test/procedural-evaluation-handoff.test.mjs @@ -225,6 +225,114 @@ test("rejects expired or excessively future-dated handoffs", async () => { ); }); +test("rejects reversed and overlong handoff validity intervals", async () => { + const evidence = await evaluationEvidence(); + const keys = await keyPair(); + const now = Math.floor(Date.now() / 1000); + const reversed = await signedHandoff(evidence, "keyverse:evaluator/procedural-v1", keys.privateKey, { + issuedAtEpochSeconds: now, + expiresAtEpochSeconds: now, + }); + const overlong = await signedHandoff(evidence, "keyverse:evaluator/procedural-v1", keys.privateKey, { + issuedAtEpochSeconds: now - 1, + expiresAtEpochSeconds: now + 300, + }); + + await assert.rejects( + verifyProceduralEvaluationHandoff(evidence, reversed, { + signerKeyId: "keyverse:evaluator/procedural-v1", + verificationKey: keys.publicKey, + }), + /evaluation_handoff_time_invalid/, + ); + await assert.rejects( + verifyProceduralEvaluationHandoff(evidence, overlong, { + signerKeyId: "keyverse:evaluator/procedural-v1", + verificationKey: keys.publicKey, + }), + /evaluation_handoff_time_invalid/, + ); +}); + +test("rejects unsupported schema and malformed signature shapes before verification", async () => { + const evidence = await evaluationEvidence(); + const keys = await keyPair(); + const handoff = await signedHandoff(evidence, "keyverse:evaluator/procedural-v1", keys.privateKey); + const trust = { signerKeyId: "keyverse:evaluator/procedural-v1", verificationKey: keys.publicKey }; + + await assert.rejects( + verifyProceduralEvaluationHandoff(evidence, { ...handoff, schemaVersion: "noema.procedural-evaluation-handoff/v2" }, trust), + /unsupported_schema/, + ); + await assert.rejects( + verifyProceduralEvaluationHandoff(evidence, { ...handoff, signature: 7 }, trust), + /evaluation_handoff_signature_invalid/, + ); + await assert.rejects( + verifyProceduralEvaluationHandoff(evidence, { ...handoff, signature: "short" }, trust), + /evaluation_handoff_signature_invalid/, + ); + await assert.rejects( + verifyProceduralEvaluationHandoff(evidence, { ...handoff, signature: "!".repeat(86) }, trust), + /evaluation_handoff_signature_invalid/, + ); +}); + +test("fails closed when decoded signature bytes or base64 decoding are invalid", async () => { + const evidence = await evaluationEvidence(); + const keys = await keyPair(); + const handoff = await signedHandoff(evidence, "keyverse:evaluator/procedural-v1", keys.privateKey); + const trust = { signerKeyId: "keyverse:evaluator/procedural-v1", verificationKey: keys.publicKey }; + + const shortDecode = vi.spyOn(globalThis, "atob").mockReturnValue("x"); + try { + await assert.rejects( + verifyProceduralEvaluationHandoff(evidence, { ...handoff, signature: "A".repeat(86) }, trust), + /evaluation_handoff_signature_invalid/, + ); + } finally { + shortDecode.mockRestore(); + } + + const throwingDecode = vi.spyOn(globalThis, "atob").mockImplementation(() => { throw new Error("decode failed"); }); + try { + await assert.rejects( + verifyProceduralEvaluationHandoff(evidence, { ...handoff, signature: "A".repeat(86) }, trust), + /evaluation_handoff_signature_invalid/, + ); + } finally { + throwingDecode.mockRestore(); + } +}); + +test("rejects invalid trust key shapes and verification-key algorithm failures", async () => { + const evidence = await evaluationEvidence(); + const keys = await keyPair(); + const handoff = await signedHandoff(evidence, "keyverse:evaluator/procedural-v1", keys.privateKey); + + await assert.rejects( + verifyProceduralEvaluationHandoff(evidence, handoff, { + signerKeyId: "keyverse:evaluator/procedural-v1", + verificationKey: null, + }), + /evaluation_handoff_signature_invalid/, + ); + await assert.rejects( + verifyProceduralEvaluationHandoff(evidence, handoff, { + signerKeyId: "keyverse:evaluator/procedural-v1", + verificationKey: "not-a-key", + }), + /evaluation_handoff_signature_invalid/, + ); + await assert.rejects( + verifyProceduralEvaluationHandoff(evidence, handoff, { + signerKeyId: "keyverse:evaluator/procedural-v1", + verificationKey: {}, + }), + /evaluation_handoff_signature_invalid/, + ); +}); + test("structural copies of authenticated handoff evidence do not retain process-local authority", async () => { const evidence = await evaluationEvidence(); const keys = await keyPair(); @@ -238,6 +346,14 @@ test("structural copies of authenticated handoff evidence do not retain process- () => assertAuthenticatedProceduralEvaluationEvidence({ ...authenticated }), /unadmitted_authenticated_evaluation/, ); + assert.throws( + () => assertAuthenticatedProceduralEvaluationEvidence(null), + /unadmitted_authenticated_evaluation/, + ); + assert.throws( + () => assertAuthenticatedProceduralEvaluationEvidence("not-evidence"), + /unadmitted_authenticated_evaluation/, + ); }); test("authenticated handoff evidence stops being authority when the signed interval expires", async () => { From 3f608285ef0f19393da7a614e921d4d47041e9fc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 20:14:21 +0900 Subject: [PATCH 07/14] docs(changelog): record signed evaluator handoff boundary --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92859f574..8fb2675f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ - Add a Noema-owned exact-claim evidence receipt contract whose execution and research producers serialize one canonical artifact that binds every receipt semantic field, including command/result/isolation/network or source revision/excerpt/retrieval policy. Admission accepts only a receipt ID from untrusted model output. The owner API first verifies the exact authenticated OpenCode-handoff manifest digest, canonical envelope bytes, reviewed producer-to-kind policy, and repository/head/workflow/run/attempt identity before it can construct an immutable typed index; admission then reconstructs each canonical artifact and verifies time/claim/artifact identity. The version-2 manifest now binds a separate producer-authenticated `ClaimEvidenceRequirement` containing the exact claim, independently required evidence kind, and `context` or `finding` publication authority. Raw current-head source lines are context only: they are withheld from finding-reference prompts and cannot publish a finding or `request_changes`; an explicitly producer-authorized source finding remains usable and retains exact path/line checks. Finding-free model `request_changes` and `blocked` verdicts cannot bypass receipt admission to publish a vacuous blocking review. Requirement/receipt kind mismatch, fixed-artifact semantic substitution, caller-supplied receipt dictionaries, model self-classification, stale identities, cross-kind receipts, marker-only sandbox output, noncanonical artifact bytes, and expired receipts fail closed before the GitHub publisher. This remains the owner prerequisite for ContextualWisdomLab/.github#1641 and issue #555. The reviewed `sandboxed_verify` adapter exists in owner source, but its actual central stdout/stderr/marker-to-manifest wiring and the trusted research producer are not yet integrated; exact-head hosted GREEN, immutable release, and the verified central consumer bump remain required. ## Unreleased +- Agent Runtime이 separately authenticated evaluator handoff의 P-256 ECDSA 서명을 composition root가 공급한 trusted public key/key id로 검증하고 exact evaluation envelope digest와 최대 300초 validity window를 결합한다. 검증된 evidence도 signed expiry 이후에는 process-local authority로 재사용할 수 없으며 structural copy·wrong key/signer/envelope·expired/future handoff는 실패-폐쇄한다. Keyverse/owner가 signer identity·key custody를 유지하고 Noema는 private key·credential discovery·provider routing·durable State / Checkpoint·Policy / Approval·publication·activation authority를 소유하지 않는다. issue #584, PR #594. - Agent Runtime의 screened procedural decision과 evaluator/profile 조건을 하나의 canonical envelope digest에 결합하고, separately authenticated evaluator handoff에서 받은 exact digest와 일치할 때만 process-local evaluation evidence로 admit한다. 이 adapter는 binding만 검증하며 digest 재계산 자체를 evaluator authentication으로 취급하지 않고, Keyverse/owner signer trust·credential·provider routing·durable State / Checkpoint·Policy / Approval·publication·activation authority를 소유하지 않는다. 모든 evidence는 `activationAuthorized:false`를 유지한다. issue #584, PR #593. - Agent Runtime의 procedural candidate screening decision을 process-local provenance로 제한한다. `assessProceduralCandidate()`가 기존 lineage·held-out·safety·score 검증을 마친 결과만 locally admitted decision으로 발행하고, State / Checkpoint나 Policy / Approval 경계가 구조만 복사·직렬화·프록시·직접 생성한 lookalike를 `assertProceduralCandidateDecision()`으로 실패-폐쇄한다. 이 brand는 evaluator authentication·persistence·approval·activation authority가 아니며 모든 decision의 `activationAuthorized:false`는 유지된다. issue #584, PR #591. - Agent Runtime의 screened procedural decision에 baseline/candidate paired evaluation receipt의 canonical SHA-256 identity를 결합한다. exact graph/context/case-set/score/safety validation 뒤 admitted holdout 순서로 정규화한 증거 semantics를 해시하므로 observation 배열 순서만 바꿔 identity를 우회할 수 없고 실제 score/safety evidence가 바뀌면 identity도 바뀐다. 이 digest는 향후 authenticated evaluator evidence와 durable State / Checkpoint retention을 정확히 결합하기 위한 local evidence identity일 뿐 signature·producer authentication·Policy / Approval·persistence·activation authority를 부여하지 않는다. issue #584, PR #592. @@ -19,7 +20,7 @@ - Workflow / Task Execution은 untrusted DAG를 execution/plan identity에 결합한 detached immutable snapshot으로 승인하고, validated array bounds 안에서만 task/dependency/state evidence를 읽는다. runnable 선택은 cross-execution·foreign·duplicate·non-canonical evidence, admitted concurrency를 초과한 running state, 성공하지 않은 prerequisite 뒤에 존재하는 causally impossible executed state를 실패-폐쇄하며, 선택 결과는 reservation이나 side-effect authority가 아닌 후보임을 명시한다. Agent Runtime lifecycle·State & Checkpoint·Workflow admission은 null·throwing accessor·revoked proxy 같은 malformed runtime input의 임의 JavaScript 예외를 각 bounded-context domain error로 정규화한다. - State & Checkpoint admission은 accepted/replay 결과와 내부 checkpoint를 모두 caller-owned alias에서 분리한 frozen snapshot으로 반환한다. TypeScript `readonly`만으로는 막을 수 없는 JavaScript 런타임 alias mutation이 승인된 checkpoint authority나 `accepted`/`replay` 분류를 사후 변경하지 못하도록 실패-폐쇄한다. - Noema의 필수 PR 워크플로 `ci`, `reviewer-ci`, `patch-validator-image`를 부동 `ubuntu-latest` 대신 명시적 `ubuntu-24.04` GitHub-hosted runner에 고정하고, 인용 여부와 무관하게 `ubuntu-latest` 회귀를 탐지하는 계약 테스트를 추가해 pre-checkout runner-assignment stall의 repository-owned selector 원인을 제거한다. 중앙 `Security Scan`의 runner/control-plane 권한은 별도 `.github` owner 경계에 유지한다. -- 비공개 취약점 보고 감사가 16 KiB 응답 상한, bounded stream 취소, canonical repository/source identity의 독립 검증, SHA-1/SHA-256 exact revision, symlink·retained-path 보호를 실패-폐쇄로 강제한다. 이 감사 결과는 live private reporting 활성화, notification staffing, 실제 advisory 대응 또는 release/deployment 완료 증거를 대신하지 않는다. +- 비공개 취약점 보고 감사가 16 KiB 응답 상한, bounded stream 취소, canonical repository/source identity의 독립 검증, SHA-1/SHA-256 exact revision, symlink·retained-path 보호를 실패-폐쇄로 강제한다. 이 감사 결과는 live private reporting 활성화·notification staffing·실제 advisory 대응 또는 release/deployment 완료 증거를 대신하지 않는다. - External scheduler evidence audits now retain source authority through final report publication: reports are owner-only, no-follow, exclusive one-shot receipts, so a concurrent rename cannot move the accepted source inode onto the report pathname and have it replaced. Source/report path and inode alias checks, single-link retained-source validation, and Unicode control sanitization remain fail closed. - revenue/transfer acquisition evidence의 `source_documents`를 임의 문자열 label 대신 stable retained artifact의 `{path, sha256}` binding으로 검증한다. Digest 일치는 보존된 bytes의 무결성만 증명하며 CRM·계약·매출·법률 기록의 진실성이나 승인 권한은 계속 별도 buyer evidence로 요구한다. - production runtime credential envelope parsing을 fail-closed로 강화한다. GitHub App PKCS#1 key의 canonical PKCS#8 변환은 유지하되, bare carriage return처럼 비정규 body bytes가 포함된 PKCS#8 PEM은 readiness/import 단계의 암묵적 정규화에 넘기지 않고 즉시 거부해 malformed secret이 ready 상태로 승인되지 않게 한다. From 878cdc48877529fbced40fc5cb1bd6e090d95606 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 20:17:22 +0900 Subject: [PATCH 08/14] fix(changelog): preserve historical vulnerability-report entry --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fb2675f1..f1d8125a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ - Workflow / Task Execution은 untrusted DAG를 execution/plan identity에 결합한 detached immutable snapshot으로 승인하고, validated array bounds 안에서만 task/dependency/state evidence를 읽는다. runnable 선택은 cross-execution·foreign·duplicate·non-canonical evidence, admitted concurrency를 초과한 running state, 성공하지 않은 prerequisite 뒤에 존재하는 causally impossible executed state를 실패-폐쇄하며, 선택 결과는 reservation이나 side-effect authority가 아닌 후보임을 명시한다. Agent Runtime lifecycle·State & Checkpoint·Workflow admission은 null·throwing accessor·revoked proxy 같은 malformed runtime input의 임의 JavaScript 예외를 각 bounded-context domain error로 정규화한다. - State & Checkpoint admission은 accepted/replay 결과와 내부 checkpoint를 모두 caller-owned alias에서 분리한 frozen snapshot으로 반환한다. TypeScript `readonly`만으로는 막을 수 없는 JavaScript 런타임 alias mutation이 승인된 checkpoint authority나 `accepted`/`replay` 분류를 사후 변경하지 못하도록 실패-폐쇄한다. - Noema의 필수 PR 워크플로 `ci`, `reviewer-ci`, `patch-validator-image`를 부동 `ubuntu-latest` 대신 명시적 `ubuntu-24.04` GitHub-hosted runner에 고정하고, 인용 여부와 무관하게 `ubuntu-latest` 회귀를 탐지하는 계약 테스트를 추가해 pre-checkout runner-assignment stall의 repository-owned selector 원인을 제거한다. 중앙 `Security Scan`의 runner/control-plane 권한은 별도 `.github` owner 경계에 유지한다. -- 비공개 취약점 보고 감사가 16 KiB 응답 상한, bounded stream 취소, canonical repository/source identity의 독립 검증, SHA-1/SHA-256 exact revision, symlink·retained-path 보호를 실패-폐쇄로 강제한다. 이 감사 결과는 live private reporting 활성화·notification staffing·실제 advisory 대응 또는 release/deployment 완료 증거를 대신하지 않는다. +- 비공개 취약점 보고 감사가 16 KiB 응답 상한, bounded stream 취소, canonical repository/source identity의 독립 검증, SHA-1/SHA-256 exact revision, symlink·retained-path 보호를 실패-폐쇄로 강제한다. 이 감사 결과는 live private reporting 활성화, notification staffing, 실제 advisory 대응 또는 release/deployment 완료 증거를 대신하지 않는다. - External scheduler evidence audits now retain source authority through final report publication: reports are owner-only, no-follow, exclusive one-shot receipts, so a concurrent rename cannot move the accepted source inode onto the report pathname and have it replaced. Source/report path and inode alias checks, single-link retained-source validation, and Unicode control sanitization remain fail closed. - revenue/transfer acquisition evidence의 `source_documents`를 임의 문자열 label 대신 stable retained artifact의 `{path, sha256}` binding으로 검증한다. Digest 일치는 보존된 bytes의 무결성만 증명하며 CRM·계약·매출·법률 기록의 진실성이나 승인 권한은 계속 별도 buyer evidence로 요구한다. - production runtime credential envelope parsing을 fail-closed로 강화한다. GitHub App PKCS#1 key의 canonical PKCS#8 변환은 유지하되, bare carriage return처럼 비정규 body bytes가 포함된 PKCS#8 PEM은 readiness/import 단계의 암묵적 정규화에 넘기지 않고 즉시 거부해 malformed secret이 ready 상태로 승인되지 않게 한다. From cc93730c581fa4a41ce218c437939434129d34ee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 20:18:18 +0900 Subject: [PATCH 09/14] style(agent-runtime): preserve procedural input newline --- src/agent-runtime/procedural-input.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agent-runtime/procedural-input.ts b/src/agent-runtime/procedural-input.ts index 7747c456c..041588b9f 100644 --- a/src/agent-runtime/procedural-input.ts +++ b/src/agent-runtime/procedural-input.ts @@ -177,4 +177,4 @@ export async function proceduralHash(value: unknown): Promise { if (bytes.length > 1_048_576) rejectProceduralInput("graph_budget_exceeded"); const digest = await crypto.subtle.digest("SHA-256", bytes); return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, "0")).join(""); -} \ No newline at end of file +} From d4c0ff4753c0281a03fb4ef1968c2708e17af252 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 20:23:48 +0900 Subject: [PATCH 10/14] test(agent-runtime): exercise reversed live handoff interval --- test/procedural-evaluation-handoff.test.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/procedural-evaluation-handoff.test.mjs b/test/procedural-evaluation-handoff.test.mjs index 56d150df0..835f9fd13 100644 --- a/test/procedural-evaluation-handoff.test.mjs +++ b/test/procedural-evaluation-handoff.test.mjs @@ -230,8 +230,8 @@ test("rejects reversed and overlong handoff validity intervals", async () => { const keys = await keyPair(); const now = Math.floor(Date.now() / 1000); const reversed = await signedHandoff(evidence, "keyverse:evaluator/procedural-v1", keys.privateKey, { - issuedAtEpochSeconds: now, - expiresAtEpochSeconds: now, + issuedAtEpochSeconds: now + 10, + expiresAtEpochSeconds: now + 5, }); const overlong = await signedHandoff(evidence, "keyverse:evaluator/procedural-v1", keys.privateKey, { issuedAtEpochSeconds: now - 1, From f2e47d726c7a89357eb6c05b84f1d8f6fced0cfd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 20:31:40 +0900 Subject: [PATCH 11/14] test(agent-runtime): reject noncanonical handoff signatures --- test/procedural-evaluation-handoff.test.mjs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test/procedural-evaluation-handoff.test.mjs b/test/procedural-evaluation-handoff.test.mjs index 835f9fd13..e57bea2d8 100644 --- a/test/procedural-evaluation-handoff.test.mjs +++ b/test/procedural-evaluation-handoff.test.mjs @@ -278,6 +278,27 @@ test("rejects unsupported schema and malformed signature shapes before verificat ); }); +test("rejects noncanonical base64url aliases for the same signature bytes", async () => { + const evidence = await evaluationEvidence(); + const keys = await keyPair(); + const handoff = await signedHandoff(evidence, "keyverse:evaluator/procedural-v1", keys.privateKey); + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + const lastIndex = alphabet.indexOf(handoff.signature.at(-1)); + const groupStart = Math.floor(lastIndex / 16) * 16; + const aliasIndex = lastIndex === groupStart ? groupStart + 1 : groupStart; + const aliasSignature = handoff.signature.slice(0, -1) + alphabet[aliasIndex]; + + assert.notEqual(aliasSignature, handoff.signature); + assert.deepEqual(Buffer.from(aliasSignature, "base64url"), Buffer.from(handoff.signature, "base64url")); + await assert.rejects( + verifyProceduralEvaluationHandoff(evidence, { ...handoff, signature: aliasSignature }, { + signerKeyId: "keyverse:evaluator/procedural-v1", + verificationKey: keys.publicKey, + }), + /evaluation_handoff_signature_invalid/, + ); +}); + test("fails closed when decoded signature bytes or base64 decoding are invalid", async () => { const evidence = await evaluationEvidence(); const keys = await keyPair(); From d774476bb52c449f72677ea5fe433ee368f8b00f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 20:34:02 +0900 Subject: [PATCH 12/14] fix(agent-runtime): require canonical handoff signatures --- src/agent-runtime/procedural-evaluation-handoff.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/agent-runtime/procedural-evaluation-handoff.ts b/src/agent-runtime/procedural-evaluation-handoff.ts index aa8bd5884..1aa267bea 100644 --- a/src/agent-runtime/procedural-evaluation-handoff.ts +++ b/src/agent-runtime/procedural-evaluation-handoff.ts @@ -70,6 +70,13 @@ function parseSignature(value: unknown): Uint8Array { if (decoded.length !== ECDSA_P256_SIGNATURE_BYTES) { rejectProceduralInput("evaluation_handoff_signature_invalid"); } + const canonical = btoa(decoded) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); + if (canonical !== value) { + rejectProceduralInput("evaluation_handoff_signature_invalid"); + } return Uint8Array.from(decoded, character => character.charCodeAt(0)); } catch { rejectProceduralInput("evaluation_handoff_signature_invalid"); From e64292c83de7cfe1b7b3eef1bca1d43b367f7336 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 20:36:55 +0900 Subject: [PATCH 13/14] test(agent-runtime): stabilize ECDSA handoff identity --- test/procedural-evaluation-handoff.test.mjs | 36 +++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/test/procedural-evaluation-handoff.test.mjs b/test/procedural-evaluation-handoff.test.mjs index e57bea2d8..ca8c44a11 100644 --- a/test/procedural-evaluation-handoff.test.mjs +++ b/test/procedural-evaluation-handoff.test.mjs @@ -299,6 +299,42 @@ test("rejects noncanonical base64url aliases for the same signature bytes", asyn ); }); +test("derives one handoff identity for ECDSA signatures of the same signed claim", async () => { + const evidence = await evaluationEvidence(); + const keys = await keyPair(); + const handoff = await signedHandoff(evidence, "keyverse:evaluator/procedural-v1", keys.privateKey); + const signatureBytes = Buffer.from(handoff.signature, "base64url"); + const curveOrder = BigInt("0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551"); + const s = BigInt(`0x${signatureBytes.subarray(32).toString("hex")}`); + const alternateS = curveOrder - s; + const alternateSignatureBytes = Buffer.concat([ + signatureBytes.subarray(0, 32), + Buffer.from(alternateS.toString(16).padStart(64, "0"), "hex"), + ]); + const alternateHandoff = { ...handoff, signature: alternateSignatureBytes.toString("base64url") }; + const message = canonicalMessage( + handoff.envelopeDigest, + handoff.signerKeyId, + handoff.issuedAtEpochSeconds, + handoff.expiresAtEpochSeconds, + ); + + assert.notEqual(alternateHandoff.signature, handoff.signature); + assert.equal( + await crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, keys.publicKey, alternateSignatureBytes, message), + true, + ); + const original = await verifyProceduralEvaluationHandoff(evidence, handoff, { + signerKeyId: "keyverse:evaluator/procedural-v1", + verificationKey: keys.publicKey, + }); + const alternate = await verifyProceduralEvaluationHandoff(evidence, alternateHandoff, { + signerKeyId: "keyverse:evaluator/procedural-v1", + verificationKey: keys.publicKey, + }); + assert.equal(alternate.handoffDigest, original.handoffDigest); +}); + test("fails closed when decoded signature bytes or base64 decoding are invalid", async () => { const evidence = await evaluationEvidence(); const keys = await keyPair(); From 2dd0a32320db5de5e97f8477a4b1d5f13115640b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 20:39:25 +0900 Subject: [PATCH 14/14] fix(agent-runtime): bind handoff identity to signed claim --- src/agent-runtime/procedural-evaluation-handoff.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/agent-runtime/procedural-evaluation-handoff.ts b/src/agent-runtime/procedural-evaluation-handoff.ts index 1aa267bea..fd8b99856 100644 --- a/src/agent-runtime/procedural-evaluation-handoff.ts +++ b/src/agent-runtime/procedural-evaluation-handoff.ts @@ -201,7 +201,6 @@ export async function verifyProceduralEvaluationHandoff( handoff.signerKeyId, handoff.issuedAtEpochSeconds, handoff.expiresAtEpochSeconds, - handoff.signature, ]), activationAuthorized: false as const, });