diff --git a/packages/gateway/src/__tests__/identity-session.test.ts b/packages/gateway/src/__tests__/identity-session.test.ts new file mode 100644 index 000000000..ff50355aa --- /dev/null +++ b/packages/gateway/src/__tests__/identity-session.test.ts @@ -0,0 +1,209 @@ +/** + * POST /api/identity/session — the scope it returns is canonical. + * + * #361 commits sessionKeyAuthDigest = sha256(canonicalize(SessionKeyAuthorization)) + * over the scope this route returns. If the route hands back the caller's arrays + * verbatim, one authorization has several digests ([a,b], [b,a], [a,a,b]). + * The scope must come back sorted (UTF-16 code-unit order) and de-duplicated, + * and it must be the SAME scope the parent signed. + */ + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import Fastify, { type FastifyInstance } from "fastify"; +import nacl from "tweetnacl"; +import { identitySessionRoutes } from "../routes/identity-session.js"; + +let app: FastifyInstance; + +beforeAll(async () => { + app = Fastify({ logger: false }); + await app.register(identitySessionRoutes); + await app.ready(); +}); + +afterAll(async () => { + await app.close(); +}); + +const hex = (bytes: Uint8Array): string => Buffer.from(bytes).toString("hex"); + +/** + * The route derives a mock principal from the id (no wallet yet): seed = the + * first 32 characters of principalAgentId, NUL padded. Mirror it to get the + * parent public key that /verify needs. + */ +function parentPublicKeyFor(principalAgentId: string): Uint8Array { + const seed = new TextEncoder().encode(principalAgentId.padEnd(32, "\0").slice(0, 32)); + return nacl.sign.keyPair.fromSeed(seed).publicKey; +} + +async function issue(payload: Record) { + return app.inject({ method: "POST", url: "/api/identity/session", payload }); +} + +describe("POST /api/identity/session", () => { + it("returns a sorted, de-duplicated scope for an unsorted, duplicated request", async () => { + const res = await issue({ + principalAgentId: "agent-skey-route-1", + scope: { + allowedActions: ["workflow_step_complete", "evidence_submit", "evidence_submit"], + contractIds: ["c2", "c1", "c2"], + }, + ttlSeconds: 300, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().sessionKey.scope).toEqual({ + allowedActions: ["evidence_submit", "workflow_step_complete"], + contractIds: ["c1", "c2"], + maxSignatures: 1000, + }); + }); + + it("returns the same scope for requests that differ only in order or duplicates", async () => { + const spellings = [ + { allowedActions: ["evidence_submit", "workflow_step_complete"], contractIds: ["c1", "c2"] }, + { allowedActions: ["workflow_step_complete", "evidence_submit"], contractIds: ["c2", "c1"] }, + { + allowedActions: ["evidence_submit", "evidence_submit", "workflow_step_complete"], + contractIds: ["c1", "c1", "c2", "c1"], + }, + ]; + + const scopes = []; + for (const scope of spellings) { + const res = await issue({ principalAgentId: "agent-skey-route-2", scope }); + expect(res.statusCode).toBe(200); + scopes.push(res.json().sessionKey.scope); + } + + expect(scopes[1]).toEqual(scopes[0]); + expect(scopes[2]).toEqual(scopes[0]); + }); + + it("orders contract ids by UTF-16 code unit, not by locale", async () => { + const res = await issue({ + principalAgentId: "agent-skey-route-3", + scope: { contractIds: ["a", "B"] }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().sessionKey.scope.contractIds).toEqual(["B", "a"]); + }); + + it("the returned key verifies through /verify with the scope exactly as returned", async () => { + const principalAgentId = "agent-skey-route-4"; + const res = await issue({ + principalAgentId, + scope: { + allowedActions: ["workflow_step_complete", "evidence_submit", "evidence_submit"], + contractIds: ["c2", "c1", "c2"], + }, + }); + expect(res.statusCode).toBe(200); + const issued = res.json(); + + const eventData = new TextEncoder().encode("route-round-trip"); + const sessionSignature = nacl.sign.detached( + eventData, + Uint8Array.from(Buffer.from(issued.sessionPrivateKey, "hex")), + ); + + const verified = await app.inject({ + method: "POST", + url: "/api/identity/session/verify", + payload: { + event: { + eventData: hex(eventData), + sessionSignature: hex(sessionSignature), + proof: { + sessionKey: issued.sessionKey, + parentPublicKey: hex(parentPublicKeyFor(principalAgentId)), + }, + }, + action: "evidence_submit", + }, + }); + + expect(verified.statusCode).toBe(200); + expect(verified.json().failures).toEqual([]); + expect(verified.json().valid).toBe(true); + }); + + describe("a malformed scope is a 400 and issues nothing", () => { + // Each row is a request body the route's own types claim cannot happen. + const malformed: Array<[label: string, scope: unknown, message: string]> = [ + [ + "a number in allowedActions", + { allowedActions: ["evidence_submit", 5] }, + "scope.allowedActions[1] must be a string", + ], + [ + "null in contractIds", + { contractIds: ["c1", null] }, + "scope.contractIds[1] must be a string", + ], + [ + "an object in contractIds", + { contractIds: [{ id: "c1" }] }, + "scope.contractIds[0] must be a string", + ], + [ + "a bare string where allowedActions belongs", + { allowedActions: "evidence_submit" }, + "scope.allowedActions must be an array of strings", + ], + [ + "a plain object where contractIds belongs", + { contractIds: { 0: "c1" } }, + "scope.contractIds must be an array of strings", + ], + ]; + + it.each(malformed)("rejects %s", async (_label, scope, message) => { + const res = await issue({ principalAgentId: "agent-skey-route-5", scope }); + + expect(res.statusCode).toBe(400); + expect(res.json()).toEqual({ error: "invalid_scope", message }); + // Nothing was issued: no key material in the body. + expect(res.body).not.toContain("sessionPrivateKey"); + }); + }); + + describe("absent and empty lists keep their meaning", () => { + const DEFAULT_ACTIONS = ["evidence_submit", "workflow_step_complete"]; + + it("issues the defaults when there is no scope at all", async () => { + const res = await issue({ principalAgentId: "agent-skey-route-6" }); + + expect(res.statusCode).toBe(200); + expect(res.json().sessionKey.scope.allowedActions).toEqual(DEFAULT_ACTIONS); + expect(res.json().sessionKey.scope.contractIds).toEqual([]); + }); + + it.each([ + ["an empty scope object", {}], + ["null lists", { allowedActions: null, contractIds: null }], + ])("falls back to the defaults for %s", async (_label, scope) => { + const res = await issue({ principalAgentId: "agent-skey-route-7", scope }); + + expect(res.statusCode).toBe(200); + expect(res.json().sessionKey.scope).toEqual({ + allowedActions: DEFAULT_ACTIONS, + contractIds: [], + maxSignatures: 1000, + }); + }); + + it("returns explicitly empty lists as empty, not as defaults", async () => { + const res = await issue({ + principalAgentId: "agent-skey-route-8", + scope: { allowedActions: [], contractIds: [] }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().sessionKey.scope.allowedActions).toEqual([]); + expect(res.json().sessionKey.scope.contractIds).toEqual([]); + }); + }); +}); diff --git a/packages/gateway/src/routes/identity-session.ts b/packages/gateway/src/routes/identity-session.ts index d4a87af97..49279328d 100644 --- a/packages/gateway/src/routes/identity-session.ts +++ b/packages/gateway/src/routes/identity-session.ts @@ -31,6 +31,29 @@ function fromHex(hex: string): Uint8Array { return bytes; } +// ── Request validation ────────────────────────────────────────────────────── + +/** + * The first problem with the scope lists in an UNTYPED request body, or null. + * + * A list that is absent (undefined or null) means "use the default", as it + * always has. A list that is present must be an array of strings; anything else + * is a 400 here rather than a 500 from issueSessionKey, which enforces the same + * rule and then sorts and de-duplicates the lists. + */ +function scopeProblem(scope: unknown): string | null { + if (scope === null || typeof scope !== "object") return null; + const lists = scope as Record; + for (const field of ["allowedActions", "contractIds"] as const) { + const value = lists[field]; + if (value === undefined || value === null) continue; + if (!Array.isArray(value)) return `scope.${field} must be an array of strings`; + const bad = value.findIndex((item) => typeof item !== "string"); + if (bad !== -1) return `scope.${field}[${bad}] must be a string`; + } + return null; +} + // ── Service singleton ─────────────────────────────────────────────────────── const sessionKeyService = new SessionKeyService(); @@ -58,6 +81,12 @@ export async function identitySessionRoutes(app: FastifyInstance) { * sessionPrivateKey: hex (ONLY returned once!) * } * + * The returned scope is canonical: allowedActions and contractIds are + * de-duplicated and sorted (UTF-16 code-unit order) whatever order or + * duplicates the request carried, and it is the scope the parent signed. + * A present scope list that is not an array of strings is a 400 + * `invalid_scope`. + * * The private key is returned exactly ONCE and never stored by the gateway. */ app.post("/api/identity/session", async (req, reply) => { @@ -76,6 +105,14 @@ export async function identitySessionRoutes(app: FastifyInstance) { }); } + const invalidScope = scopeProblem(body.scope); + if (invalidScope) { + return reply.status(400).send({ + error: "invalid_scope", + message: invalidScope, + }); + } + try { // Generate a mock principal keypair for the authenticated user. // In production, the principal's private key would come from the diff --git a/packages/verifier/src/workflow/__tests__/ephemeral-identity-canonical-scope.test.ts b/packages/verifier/src/workflow/__tests__/ephemeral-identity-canonical-scope.test.ts new file mode 100644 index 000000000..2b883776d --- /dev/null +++ b/packages/verifier/src/workflow/__tests__/ephemeral-identity-canonical-scope.test.ts @@ -0,0 +1,507 @@ +/** + * Canonical scope for issued session keys (answers #361 E7 F4). + * + * scope.allowedActions and scope.contractIds are SETS in meaning but ARRAYS on + * the wire. #361 commits sessionKeyAuthDigest = sha256(canonicalize( + * SessionKeyAuthorization)), so unless the issuer emits ONE spelling of a + * scope, [a,b], [b,a] and [a,a,b] are three digests for one authorization. + * + * The canonical spelling is: duplicates dropped, then sorted in UTF-16 + * code-unit order (Array.prototype.sort() with no comparator, never + * localeCompare), built BEFORE the struct is signed so the emitted object and + * the signature agree. + * + * Randomness and time are stubbed so two issuances can be compared byte for + * byte: same sessionId, same session key, same issuedAt and expiresAt. + * Ed25519 is deterministic, so equal canonical bytes <=> equal parentSignature. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import nacl from "tweetnacl"; +import type { + AgentRegistryId, + PrincipalKey, + SessionAction, + SessionKey, + SessionKeyConfig, + SessionScope, +} from "@pcc/spec"; +import { DEFAULT_SESSION_KEY_CONFIG } from "@pcc/spec"; +import { SessionKeyService } from "../ephemeral-identity.js"; + +// --------------------------------------------------------------------------- +// Controllable randomUUID. Transparent (real UUIDs) unless a test pins it. +// --------------------------------------------------------------------------- + +const rng = vi.hoisted(() => ({ uuid: undefined as string | undefined })); + +vi.mock("node:crypto", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, randomUUID: () => rng.uuid ?? actual.randomUUID() }; +}); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const FIXED_NOW_MS = 1_700_000_000_000; +const FIXED_ISSUED_AT = FIXED_NOW_MS / 1000; +const FIXED_UUID = "00000000-0000-4000-8000-000000000001"; + +// Keys come from fixed seeds (tweetnacl), never from real key material. +const principalKeypair = nacl.sign.keyPair.fromSeed(new Uint8Array(32).fill(7)); +const sessionKeypair = nacl.sign.keyPair.fromSeed(new Uint8Array(32).fill(9)); + +const principal: PrincipalKey = { + agentId: "eip155:84532:0xDeadBeef00000000000000000000000000000001" as AgentRegistryId, + walletAddress: "0xDeadBeef00000000000000000000000000000001", + publicKey: principalKeypair.publicKey, +}; + +const service = new SessionKeyService(); + +const hex = (bytes: Uint8Array): string => Buffer.from(bytes).toString("hex"); + +/** The task's example: unsorted, with a duplicate in each list. */ +const MESSY: Pick = { + allowedActions: ["workflow_step_complete", "evidence_submit", "evidence_submit"], + contractIds: ["c2", "c1", "c2"], +}; + +/** The one canonical spelling of MESSY. */ +const CANONICAL: Pick = { + allowedActions: ["evidence_submit", "workflow_step_complete"], + contractIds: ["c1", "c2"], +}; + +/** Inputs that all mean the same authorization, spelled four ways. */ +const VARIANTS: Array<{ label: string; scope: Partial }> = [ + { label: "already canonical", scope: { ...CANONICAL } }, + { + label: "reordered", + scope: { + allowedActions: ["workflow_step_complete", "evidence_submit"], + contractIds: ["c2", "c1"], + }, + }, + { + label: "duplicated", + scope: { + allowedActions: ["evidence_submit", "evidence_submit", "workflow_step_complete"], + contractIds: ["c1", "c1", "c2"], + }, + }, + { label: "reordered and duplicated", scope: { ...MESSY } }, +]; + +/** The documented canonical-bytes layout, written out independently of the + * production helper: what the parent signed before AND after this change. */ +function referenceCanonicalJson(sk: Omit): string { + return JSON.stringify({ + sessionId: sk.sessionId, + parentAgentId: sk.parentAgentId, + publicKey: hex(sk.publicKey), + issuedAt: sk.issuedAt, + expiresAt: sk.expiresAt, + scope: { + allowedActions: [...sk.scope.allowedActions].sort(), + contractIds: [...sk.scope.contractIds].sort(), + maxSignatures: sk.scope.maxSignatures, + }, + ...(sk.derivationPath !== undefined ? { derivationPath: sk.derivationPath } : {}), + }); +} + +function issue(scope?: Partial) { + return service.issueSessionKey({ + principal, + principalPrivateKey: principalKeypair.secretKey, + scope, + }); +} + +/** Issue a key and capture the exact bytes the parent signed. */ +function issueCapturing(scope?: Partial) { + const detached = vi.spyOn(nacl.sign, "detached"); + try { + const issued = issue(scope); + expect(detached).toHaveBeenCalledTimes(1); + const signed = detached.mock.calls[0]![0]; + return { ...issued, signedJson: new TextDecoder().decode(signed) }; + } finally { + // Restore at once: verification needs the real nacl.sign.detached.verify. + detached.mockRestore(); + } +} + +function verifyIssued(issued: { sessionKey: SessionKey; sessionPrivateKey: Uint8Array }, action = "evidence_submit") { + const event = service.signEvent({ + eventData: new TextEncoder().encode("canonical-scope-evidence"), + sessionKey: issued.sessionKey, + sessionPrivateKey: issued.sessionPrivateKey, + parentPublicKey: principalKeypair.publicKey, + }); + return service.verifySessionSignedEvent({ event, action }); +} + +beforeEach(() => { + rng.uuid = FIXED_UUID; + vi.spyOn(Date, "now").mockReturnValue(FIXED_NOW_MS); +}); + +afterEach(() => { + rng.uuid = undefined; + vi.restoreAllMocks(); +}); + +// --------------------------------------------------------------------------- +// issueSessionKey +// --------------------------------------------------------------------------- + +describe("issueSessionKey emits a canonical scope", () => { + beforeEach(() => { + vi.spyOn(nacl.sign, "keyPair").mockImplementation(() => sessionKeypair); + }); + + it("(a) sorts and de-duplicates allowedActions and contractIds in the emitted scope", () => { + const { sessionKey } = issue({ ...MESSY, maxSignatures: 7 }); + + expect(sessionKey.scope).toEqual({ ...CANONICAL, maxSignatures: 7 }); + }); + + it("(a) orders by UTF-16 code unit, not by locale", () => { + // Locale order puts "a" before "B" and e-acute before "z"; code-unit order + // is the reverse of both. + const { sessionKey } = issue({ contractIds: ["a", "B", "z", "é", "b", "A"] }); + + expect(sessionKey.scope.contractIds).toEqual(["A", "B", "a", "b", "z", "é"]); + }); + + it("(a) orders by code UNIT, so a surrogate pair sorts before U+FF5E (code-point order would not)", () => { + // U+1F600 is the pair D83D DE00, and 0xD83D < 0xFF5E, so code-unit order puts + // it first. Its code point (0x1F600) is larger, so code-point and UTF-8 byte + // order put it last. Any verifier recomputing the digest must match this. + const { sessionKey } = issue({ contractIds: ["~", "😀"] }); + + expect(sessionKey.scope.contractIds).toEqual(["😀", "~"]); + }); + + it("(a) neither aliases nor reorders the caller's arrays", () => { + const allowedActions = [...MESSY.allowedActions]; + const contractIds = [...MESSY.contractIds]; + + const { sessionKey } = issue({ allowedActions, contractIds }); + + expect(allowedActions).toEqual(MESSY.allowedActions); + expect(contractIds).toEqual(MESSY.contractIds); + expect(sessionKey.scope.allowedActions).not.toBe(allowedActions); + expect(sessionKey.scope.contractIds).not.toBe(contractIds); + + // A later edit to the caller's array cannot change what was signed. + contractIds.push("c9"); + expect(sessionKey.scope.contractIds).toEqual(CANONICAL.contractIds); + }); + + it("(a) canonicalizes defaults that come from the config", () => { + const config: SessionKeyConfig = { + defaultTTLSeconds: 60, + maxTTLSeconds: 120, + defaultMaxSignatures: 5, + defaultAllowedActions: ["heartbeat", "evidence_submit", "heartbeat"], + }; + + const { sessionKey } = service.issueSessionKey({ + principal, + principalPrivateKey: principalKeypair.secretKey, + config, + }); + + expect(sessionKey.scope.allowedActions).toEqual(["evidence_submit", "heartbeat"]); + }); + + it.each(VARIANTS)("(b) $label input emits the canonical scope arrays", ({ scope }) => { + const { sessionKey } = issue(scope); + + expect(sessionKey.scope.allowedActions).toEqual(CANONICAL.allowedActions); + expect(sessionKey.scope.contractIds).toEqual(CANONICAL.contractIds); + }); + + it.each(VARIANTS)( + "(b) $label input signs the same canonical bytes as the canonical input", + ({ scope }) => { + const reference = issueCapturing({ ...CANONICAL }); + const run = issueCapturing(scope); + + expect(run.sessionKey.sessionId).toBe(reference.sessionKey.sessionId); + expect(run.sessionKey.issuedAt).toBe(FIXED_ISSUED_AT); + expect(run.signedJson).toBe(reference.signedJson); + expect(hex(run.sessionKey.parentSignature)).toBe(hex(reference.sessionKey.parentSignature)); + }, + ); + + it("(c) a duplicate changes the signed canonical bytes, because sort keeps duplicates", () => { + const duplicated = issueCapturing({ + allowedActions: ["evidence_submit", "evidence_submit"], + contractIds: ["c1", "c1"], + }); + const once = issueCapturing({ + allowedActions: ["evidence_submit"], + contractIds: ["c1"], + }); + + // One authorization, so one set of canonical bytes. + expect(duplicated.signedJson).toBe(once.signedJson); + }); + + it("(c) reordering alone is already absorbed by the canonicalizer's sort", () => { + const ordered = issueCapturing({ ...CANONICAL }); + const reversed = issueCapturing({ + allowedActions: ["workflow_step_complete", "evidence_submit"], + contractIds: ["c2", "c1"], + }); + + expect(reversed.signedJson).toBe(ordered.signedJson); + }); + + it("(d) the parent signed exactly the canonical form of what was emitted, and it verifies", () => { + const issued = issueCapturing({ ...MESSY }); + + // The signature and the emitted object must agree. + expect(issued.signedJson).toBe(referenceCanonicalJson(issued.sessionKey)); + + const result = verifyIssued(issued); + expect(result.failures).toEqual([]); + expect(result.valid).toBe(true); + expect(result.principalAgentId).toBe(principal.agentId); + }); +}); + +// --------------------------------------------------------------------------- +// A malformed scope is refused, and before anything is signed +// --------------------------------------------------------------------------- + +describe("a malformed scope is refused before anything is signed", () => { + const parentSeed = Uint8Array.from({ length: 32 }, (_, i) => i + 1); + + // Every row is something an untyped JSON request body can carry past the types. + const malformed: Array<[label: string, scope: unknown, message: RegExp]> = [ + [ + "a number in allowedActions", + { allowedActions: ["evidence_submit", 5] }, + /^scope\.allowedActions\[1\] must be a string, got number$/, + ], + [ + "null in contractIds", + { contractIds: ["c1", null] }, + /^scope\.contractIds\[1\] must be a string, got null$/, + ], + [ + "undefined in contractIds", + { contractIds: ["c1", undefined] }, + /^scope\.contractIds\[1\] must be a string, got undefined$/, + ], + [ + "a hole in a sparse contractIds", + { contractIds: ["c1", , "c3"] }, + /^scope\.contractIds\[1\] must be a string, got undefined$/, + ], + [ + "an object in contractIds", + { contractIds: [{ id: "c1" }] }, + /^scope\.contractIds\[0\] must be a string, got object$/, + ], + [ + "a nested array in allowedActions", + { allowedActions: [["evidence_submit"]] }, + /^scope\.allowedActions\[0\] must be a string, got array$/, + ], + [ + "a bare string where allowedActions belongs", + { allowedActions: "evidence_submit" }, + /^scope\.allowedActions must be an array of strings, got string$/, + ], + [ + "a plain object where contractIds belongs", + { contractIds: { 0: "c1" } }, + /^scope\.contractIds must be an array of strings, got object$/, + ], + ]; + + it.each(malformed)("issueSessionKey rejects %s", (_label, scope, message) => { + expect(() => issue(scope as Partial)).toThrow(message); + }); + + it.each(malformed)("deriveSessionKey rejects %s", (_label, scope, message) => { + expect(() => + service.deriveSessionKey({ + parentSeed, + path: "m/8004'/84532'/1'/0'", + principal, + principalPrivateKey: principalKeypair.secretKey, + scope: scope as Partial, + }), + ).toThrow(message); + }); + + it("never reaches the signer", () => { + const detached = vi.spyOn(nacl.sign, "detached"); + try { + expect(() => issue({ contractIds: [42] } as unknown as Partial)).toThrow(); + expect(detached).not.toHaveBeenCalled(); + } finally { + detached.mockRestore(); + } + }); + + it("still accepts empty lists and returns them empty", () => { + const { sessionKey } = issue({ allowedActions: [], contractIds: [] }); + + expect(sessionKey.scope.allowedActions).toEqual([]); + expect(sessionKey.scope.contractIds).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// Keys issued before the issuer canonicalized must keep verifying +// --------------------------------------------------------------------------- + +describe("keys issued before this change still verify", () => { + /** + * Hand-sign a key the way the old issuer did: scope exactly as the caller + * gave it, parent signature over the canonical form that SORTS but does not + * de-duplicate. + */ + function legacyKey(scope: SessionScope) { + const body = { + sessionId: "legacy-session-0001", + parentAgentId: principal.agentId, + publicKey: sessionKeypair.publicKey, + issuedAt: FIXED_ISSUED_AT, + expiresAt: FIXED_ISSUED_AT + 3600, + scope, + }; + const parentSignature = nacl.sign.detached( + new TextEncoder().encode(referenceCanonicalJson(body)), + principalKeypair.secretKey, + ); + return { sessionKey: { ...body, parentSignature }, sessionPrivateKey: sessionKeypair.secretKey }; + } + + it.each([ + { + label: "sorted and de-duplicated", + allowedActions: ["evidence_submit", "workflow_step_complete"] as SessionAction[], + contractIds: ["c1", "c2"], + }, + { + label: "sorted but still carrying duplicates", + allowedActions: ["evidence_submit", "evidence_submit", "workflow_step_complete"] as SessionAction[], + contractIds: ["c1", "c2", "c2"], + }, + { + label: "unsorted with duplicates, as the old issuer emitted it", + allowedActions: [...MESSY.allowedActions], + contractIds: [...MESSY.contractIds], + }, + ])("verifies a key whose scope is $label", ({ allowedActions, contractIds }) => { + const issued = legacyKey({ allowedActions, contractIds, maxSignatures: 1000 }); + + const event = service.signEvent({ + eventData: new TextEncoder().encode("legacy-evidence"), + sessionKey: issued.sessionKey, + sessionPrivateKey: issued.sessionPrivateKey, + parentPublicKey: principalKeypair.publicKey, + }); + const result = service.verifySessionSignedEvent({ + event, + action: "evidence_submit", + currentTimestamp: FIXED_ISSUED_AT + 10, + }); + + expect(result.failures).toEqual([]); + expect(result.valid).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// deriveSessionKey builds its scope the same way +// --------------------------------------------------------------------------- + +describe("deriveSessionKey emits the same canonical scope", () => { + const parentSeed = Uint8Array.from({ length: 32 }, (_, i) => i + 1); + const path = "m/8004'/84532'/1'/0'"; + + function derive(scope?: Partial) { + return service.deriveSessionKey({ + parentSeed, + path, + principal, + principalPrivateKey: principalKeypair.secretKey, + scope, + }); + } + + it("sorts and de-duplicates the emitted scope", () => { + const { sessionKey } = derive({ ...MESSY }); + + expect(sessionKey.scope).toEqual({ + ...CANONICAL, + maxSignatures: DEFAULT_SESSION_KEY_CONFIG.defaultMaxSignatures, + }); + }); + + it("verifies end to end with a messy input scope", () => { + const derived = derive({ ...MESSY }); + + const result = verifyIssued(derived); + expect(result.failures).toEqual([]); + expect(result.valid).toBe(true); + }); +}); + +// astra pack 145, F1 (MEDIUM): validation and canonicalization must consume the SAME +// values. A direct service caller (JSON cannot carry these) can hand an array whose +// iterator, or an index accessor, yields something other than what the index check saw. +describe("validation and canonicalization read each element exactly once (astra pack 145 F1)", () => { + it("[neg] an array whose iterator yields an unvalidated value is refused, never signed", () => { + const contractIds = ["c1"]; + (contractIds as unknown as { [Symbol.iterator]: () => Iterator })[Symbol.iterator] = function* () { + yield 42 as unknown as string; + }; + expect(() => issue({ contractIds })).toThrow(); + }); + + it("[neg] an accessor that returns a string on its first read and a number afterwards is refused or canonicalized from the first read", () => { + const contractIds: string[] = []; + let reads = 0; + Object.defineProperty(contractIds, 0, { + enumerable: true, + configurable: true, + get() { + reads += 1; + return reads === 1 ? "c1" : (7 as unknown as string); + }, + }); + contractIds.length = 1; + let emitted: string[] | undefined; + try { + emitted = issue({ contractIds }).sessionKey.scope.contractIds; + } catch { + emitted = undefined; // refusing is also acceptable + } + if (emitted !== undefined) { + expect(emitted).toEqual(["c1"]); // only the validated first read may be signed + expect(emitted.every((x) => typeof x === "string")).toBe(true); + } + expect(reads).toBe(1); // each element is read exactly once + }); + + it("[neg] the same holds for allowedActions (the shared helper also backs deriveSessionKey)", () => { + const allowedActions = ["evidence_submit"]; + (allowedActions as unknown as { [Symbol.iterator]: () => Iterator })[Symbol.iterator] = function* () { + yield { not: "a string" } as unknown as string; + }; + expect(() => issue({ allowedActions: allowedActions as never })).toThrow(); + }); +}); + diff --git a/packages/verifier/src/workflow/__tests__/ephemeral-identity.test.ts b/packages/verifier/src/workflow/__tests__/ephemeral-identity.test.ts index 0ab6bd8ba..ae4921f68 100644 --- a/packages/verifier/src/workflow/__tests__/ephemeral-identity.test.ts +++ b/packages/verifier/src/workflow/__tests__/ephemeral-identity.test.ts @@ -125,9 +125,12 @@ describe("SessionKeyService", () => { scope: customScope, }); + // The emitted scope is canonical (de-duplicated, sorted by UTF-16 code + // unit), so the deliberately unsorted input above comes back sorted. + // See ephemeral-identity-canonical-scope.test.ts. expect(sessionKey.scope.allowedActions).toEqual([ - "touchstone_response", "attestation_sign", + "touchstone_response", ]); expect(sessionKey.scope.contractIds).toEqual([ "contract-alpha", diff --git a/packages/verifier/src/workflow/ephemeral-identity.ts b/packages/verifier/src/workflow/ephemeral-identity.ts index 62666ee6b..9ed57056e 100644 --- a/packages/verifier/src/workflow/ephemeral-identity.ts +++ b/packages/verifier/src/workflow/ephemeral-identity.ts @@ -45,6 +45,85 @@ function toHex(bytes: Uint8Array): string { .join(""); } +/** + * Name a value's type for an error message, telling "null" and "array" apart + * from the generic "object". + */ +function describeType(value: unknown): string { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + return typeof value; +} + +/** + * Canonical form of one scope list (allowedActions or contractIds): every + * element must be a string, duplicates are dropped, and the rest is sorted in + * UTF-16 code-unit order. That is Array.prototype.sort() with NO comparator; + * never localeCompare, whose order depends on the host's locale and ICU data. + * + * Returns a fresh array, so the emitted scope never aliases the caller's. + * + * @throws If `value` is not an array or any element is not a string. A bare + * string is refused too: spreading it would quietly turn "abc" into + * ["a", "b", "c"]. + */ +function canonicalScopeList(field: string, value: unknown): string[] { + if (!Array.isArray(value)) { + throw new Error(`${field} must be an array of strings, got ${describeType(value)}`); + } + // Index loop, not forEach: a sparse array's holes must be seen as undefined. + // Each element is read EXACTLY ONCE into a local, validated, and copied into a + // fresh snapshot; only the snapshot is de-duplicated and sorted. Validating + // value[i] but then consuming `new Set(value)` would read the caller's array a + // second time through its iterator, so a custom Symbol.iterator or an accessor + // that changes between reads could slip an unvalidated element past the check + // and into the signed scope (astra pack 145, F1). + // An array with its own iterator is not a plain JSON-shaped list. Refuse it rather + // than guess which of its two "contents" the caller meant (defense in depth; the + // snapshot below already ensures only validated reads are signed). + if (Object.prototype.hasOwnProperty.call(value, Symbol.iterator)) { + throw new Error(`${field} must be a plain array of strings (a custom iterator is not accepted)`); + } + const length = value.length; + const snapshot: string[] = []; + for (let i = 0; i < length; i++) { + const element: unknown = value[i]; + if (typeof element !== "string") { + throw new Error(`${field}[${i}] must be a string, got ${describeType(element)}`); + } + snapshot.push(element); + } + return [...new Set(snapshot)].sort(); +} + +/** + * Resolve the caller's scope overrides against the config defaults and return + * the CANONICAL scope: allowedActions and contractIds de-duplicated and sorted. + * + * issueSessionKey and deriveSessionKey call this BEFORE the SessionKey struct + * is signed, so one scope has one spelling everywhere: the emitted object, the + * bytes the parent signs, and any digest taken over the emitted authorization. + * Left verbatim, [a,b], [b,a] and [a,a,b] are three spellings (three digests) + * of a single authorization. + * + * canonicalSessionKeyBytes below still only sorts; it does not de-duplicate. + * Keys issued before this existed were signed over sorted-but-duplicated + * arrays, and de-duplicating there would change the bytes they verify against. + */ +function buildCanonicalScope( + overrides: Partial | undefined, + config: SessionKeyConfig, +): SessionScope { + return { + allowedActions: canonicalScopeList( + "scope.allowedActions", + overrides?.allowedActions ?? config.defaultAllowedActions, + ) as SessionAction[], + contractIds: canonicalScopeList("scope.contractIds", overrides?.contractIds ?? []), + maxSignatures: overrides?.maxSignatures ?? config.defaultMaxSignatures, + }; +} + /** * Produce the canonical byte representation of a SessionKey struct * for signing/verification. @@ -55,6 +134,10 @@ function toHex(bytes: Uint8Array): string { * Deterministic JSON: sorted keys, no whitespace, binary fields as hex. * This format is stable across JS engines because we control key order explicitly. * + * The scope lists are sorted here as defense in depth, but NOT de-duplicated: + * a key signed over sorted-with-duplicates arrays (issued before the issuers + * canonicalized their scope) must keep verifying. See buildCanonicalScope. + * * `derivationPath` is INCLUDED in the canonical form ONLY when present. * This keeps backward compatibility: a legacy sessionKey (no derivationPath) * produces the same canonical bytes it did before the field was added, so @@ -113,7 +196,9 @@ export class SessionKeyService { * * @param params.principal - The principalKey (registered agent identity) * @param params.principalPrivateKey - Ed25519 private key (64 bytes in tweetnacl format) - * @param params.scope - Optional scope overrides (merged with defaults) + * @param params.scope - Optional scope overrides (merged with defaults). The + * emitted scope is canonical: allowedActions and contractIds come back + * de-duplicated and sorted (UTF-16 code-unit order), in new arrays. * @param params.ttlSeconds - Optional TTL override * @param params.config - Optional config override * @@ -121,6 +206,7 @@ export class SessionKeyService { * * @throws If TTL exceeds maxTTLSeconds * @throws If principalPrivateKey length is invalid + * @throws If scope.allowedActions or scope.contractIds is not an array of strings */ issueSessionKey(params: { principal: PrincipalKey; @@ -152,12 +238,10 @@ export class SessionKeyService { // Generate a fresh Ed25519 keypair for the session const sessionKeypair = nacl.sign.keyPair(); - // Build scope with defaults - const scope: SessionScope = { - allowedActions: (params.scope?.allowedActions ?? config.defaultAllowedActions) as SessionAction[], - contractIds: params.scope?.contractIds ?? [], - maxSignatures: params.scope?.maxSignatures ?? config.defaultMaxSignatures, - }; + // Build the CANONICAL scope (defaults applied, lists de-duplicated and + // sorted) BEFORE anything is signed, so the emitted scope and the + // signature describe the same bytes. + const scope = buildCanonicalScope(params.scope, config); const now = Math.floor(Date.now() / 1000); @@ -216,7 +300,9 @@ export class SessionKeyService { * Used to populate parentAgentId on the SessionKey struct. * @param params.principalPrivateKey - The principalKey's 64-byte tweetnacl * secretKey. Used to sign the SessionKey struct (authorization). - * @param params.scope - Optional scope overrides (merged with defaults) + * @param params.scope - Optional scope overrides (merged with defaults). The + * emitted scope is canonical, exactly as for issueSessionKey: allowedActions + * and contractIds de-duplicated and sorted (UTF-16 code-unit order). * @param params.ttlSeconds - Optional TTL override * @param params.config - Optional config override * @@ -226,6 +312,7 @@ export class SessionKeyService { * * @throws If the path is non-hardened or malformed (via derivePath) * @throws If TTL exceeds maxTTLSeconds, TTL <= 0, or principalPrivateKey length is wrong + * @throws If scope.allowedActions or scope.contractIds is not an array of strings */ deriveSessionKey(params: { parentSeed: Uint8Array; @@ -270,14 +357,9 @@ export class SessionKeyService { // returns (keeps the signEvent/verify API identical for both modes). const sessionKeypair = nacl.sign.keyPair.fromSeed(derived.privateKey); - // Build scope with defaults (identical logic to issueSessionKey) - const scope: SessionScope = { - allowedActions: (params.scope?.allowedActions ?? - config.defaultAllowedActions) as SessionAction[], - contractIds: params.scope?.contractIds ?? [], - maxSignatures: - params.scope?.maxSignatures ?? config.defaultMaxSignatures, - }; + // Build the CANONICAL scope (same helper as issueSessionKey) BEFORE the + // struct is signed, so derived keys get the same single spelling. + const scope = buildCanonicalScope(params.scope, config); const now = Math.floor(Date.now() / 1000);