diff --git a/.gitignore b/.gitignore index b427c91..4ecedd4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ dist/ node_modules/ -target/ \ No newline at end of file +target/ +bench/.trace/ diff --git a/bench/README.md b/bench/README.md new file mode 100644 index 0000000..91eba34 --- /dev/null +++ b/bench/README.md @@ -0,0 +1,27 @@ +# TypeScript benchmarks + +The harness separates signature processing, WebCrypto, and key setup. + +| Command | Purpose | +| --------------------- | -------------------------------------------- | +| `npm run bench` | Compare signature and JWK thumbprint latency | +| `npm run bench:trace` | Export CPU, heap, and WebCrypto profiles | + +Tracing defaults to Web Bot Auth verification. Select another operation and +iteration count with environment variables: + +```sh +OP=coreSign ITERS=10000 npm run bench:trace +``` + +Operations are `coreSign`, `coreVerify`, `sign`, `verify`, `signerFromJWK`, and +`verifierFromJWK`. + +Trace artifacts are written under `bench/.trace/`: + +- `.cpuprofile`: open in speedscope or Chrome DevTools Performance +- `.heapprofile`: open in Chrome DevTools Memory to find allocation sites +- `.trace.json`: open in Perfetto to inspect WebCrypto call duration + +CPU, heap, and WebCrypto data are collected in separate passes so tracing does +not distort the profiles. diff --git a/bench/fixtures.mjs b/bench/fixtures.mjs new file mode 100644 index 0000000..fa9c00c --- /dev/null +++ b/bench/fixtures.mjs @@ -0,0 +1,103 @@ +import { readFile } from "node:fs/promises"; +import { createSignatureSync, verifySignature } from "http-message-sig"; +import { sign, verify } from "web-bot-auth"; +import { signerFromJWK, verifierFromJWK } from "web-bot-auth/crypto"; + +const key = JSON.parse( + await readFile( + new URL("../examples/rfc9421-keys/ed25519.json", import.meta.url), + "utf8" + ) +); + +const request = { + kind: "request", + method: "POST", + targetUri: "https://example.com/foo?param=Value&Pet=dog", + requestTarget: "/foo?param=Value&Pet=dog", + fields: [ + { name: "Host", value: "example.com" }, + { name: "Date", value: "Tue, 20 Apr 2021 02:07:55 GMT" }, + { name: "Content-Type", value: "application/json" }, + { name: "Content-Digest", value: "sha-256=:YWJjZA==:" }, + { name: "Content-Length", value: "18" }, + ], +}; +const components = [ + "@method", + "@authority", + "@path", + "content-digest", + "content-length", + "content-type", +]; +const coreSignature = new Uint8Array([1, 2, 3]); +const coreSigner = { + algorithm: "test-alg", + sign() { + return coreSignature; + }, +}; +const coreVerifier = { + algorithm: "test-alg", + verify() { + return true; + }, +}; +const coreOptions = { + components, + parameters: { created: 1_618_884_475, alg: "test-alg", keyid: "key" }, + signer: coreSigner, +}; +const coreFields = createSignatureSync(request, coreOptions); +const signedDescriptor = { + ...request, + fields: [ + ...request.fields, + { name: "Signature", value: coreFields.signature }, + { name: "Signature-Input", value: coreFields.signatureInput }, + ], +}; +const coreVerifyOptions = { + policy: { + algorithms: ["test-alg"], + requiredComponents: components, + requiredParameters: ["created", "keyid"], + now: 1_618_884_500, + }, + resolveVerifier: () => coreVerifier, +}; + +const signer = await signerFromJWK(key); +const verifier = await verifierFromJWK(key); +const created = new Date("2025-01-01T00:00:00Z"); +const expires = new Date("2025-01-01T01:00:00Z"); +const now = new Date("2025-01-01T00:30:00Z"); +const webRequest = new Request("https://example.com/resource", { + headers: { + "content-type": "application/json", + "signature-agent": 'sig1="https://example.com/agent";type=directory', + }, +}); +const webOptions = { + signer, + created, + expires, + signatureAgentKey: "sig1", + additionalComponents: ["content-type"], +}; +const webFields = await sign(webRequest, webOptions); +const webHeaders = new Headers(webRequest.headers); +webHeaders.set("signature", webFields.signature); +webHeaders.set("signature-input", webFields.signatureInput); +const signedWebRequest = new Request(webRequest, { headers: webHeaders }); +const webVerifyOptions = { now, resolver: () => verifier }; + +export const operations = Object.freeze({ + coreSign: () => createSignatureSync(request, coreOptions), + coreVerify: () => verifySignature(signedDescriptor, coreVerifyOptions), + sign: () => sign(webRequest, webOptions), + verify: () => verify(signedWebRequest, webVerifyOptions), + signerFromJWK: () => signerFromJWK(key), + verifierFromJWK: () => verifierFromJWK(key), +}); diff --git a/bench/signatures.bench.ts b/bench/signatures.bench.ts new file mode 100644 index 0000000..0261a6b --- /dev/null +++ b/bench/signatures.bench.ts @@ -0,0 +1,19 @@ +import { bench, describe } from "vitest"; +import { operations } from "./fixtures.mjs"; + +const options = { + time: 2_000, + warmupTime: 1_000, +}; + +describe("HTTP message signatures", () => { + bench("core sign without crypto", operations.coreSign, options); + bench("core verify without crypto", operations.coreVerify, options); +}); + +describe("Web Bot Auth", () => { + bench("sign Ed25519", operations.sign, options); + bench("verify Ed25519", operations.verify, options); + bench("create Ed25519 signer", operations.signerFromJWK, options); + bench("create Ed25519 verifier", operations.verifierFromJWK, options); +}); diff --git a/bench/trace.mjs b/bench/trace.mjs new file mode 100644 index 0000000..b7c1e7c --- /dev/null +++ b/bench/trace.mjs @@ -0,0 +1,82 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { Session } from "node:inspector/promises"; +import { operations } from "./fixtures.mjs"; + +const operationName = process.env.OP ?? "verify"; +const iterations = Number(process.env.ITERS ?? "2000"); +const operation = operations[operationName]; +if (operation === undefined) { + throw new Error( + `unknown OP=${operationName} (one of: ${Object.keys(operations).join(", ")})` + ); +} +if (!Number.isSafeInteger(iterations) || iterations <= 0) { + throw new Error("ITERS must be a positive integer"); +} + +const events = []; +function instrumentWebCrypto() { + const origin = performance.now(); + const subtle = crypto.subtle; + for (const name of Object.getOwnPropertyNames( + Object.getPrototypeOf(subtle) + )) { + if (name === "constructor" || typeof subtle[name] !== "function") continue; + const original = subtle[name].bind(subtle); + subtle[name] = (...arguments_) => { + const start = (performance.now() - origin) * 1_000; + return Promise.resolve(original(...arguments_)).finally(() => { + events.push({ + name, + ph: "X", + pid: 1, + tid: 1, + ts: start, + dur: (performance.now() - origin) * 1_000 - start, + }); + }); + }; + } +} + +async function run() { + for (let index = 0; index < iterations; index++) await operation(); +} + +for (let index = 0; index < Math.min(iterations, 200); index++) + await operation(); + +const cpuSession = new Session(); +cpuSession.connect(); +await cpuSession.post("Profiler.enable"); +await cpuSession.post("Profiler.setSamplingInterval", { interval: 50 }); +await cpuSession.post("Profiler.start"); +await run(); +const { profile } = await cpuSession.post("Profiler.stop"); +cpuSession.disconnect(); + +const heapSession = new Session(); +heapSession.connect(); +await heapSession.post("HeapProfiler.enable"); +await heapSession.post("HeapProfiler.startSampling", { + samplingInterval: 4_096, + includeObjectsCollectedByMajorGC: true, + includeObjectsCollectedByMinorGC: true, +}); +await run(); +const { profile: heap } = await heapSession.post("HeapProfiler.stopSampling"); +heapSession.disconnect(); + +instrumentWebCrypto(); +await run(); + +await mkdir("bench/.trace", { recursive: true }); +const output = `bench/.trace/${operationName}`; +await Promise.all([ + writeFile(`${output}.cpuprofile`, JSON.stringify(profile)), + writeFile(`${output}.heapprofile`, JSON.stringify(heap)), + writeFile(`${output}.trace.json`, JSON.stringify({ traceEvents: events })), +]); +console.log( + `wrote ${output}.{cpuprofile,heapprofile,trace.json} (${events.length} WebCrypto calls)` +); diff --git a/package.json b/package.json index b9bc4c3..1e59ceb 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,11 @@ "author": "Thibault Meunier", "private": true, "scripts": { + "bench": "npm run build && vitest bench --run", + "bench:trace": "npm run build && node bench/trace.mjs", "build": "npm run build -w http-message-sig -w jsonwebkey-thumbprint -w web-bot-auth", - "format": "prettier --write . && oxlint examples packages --ignore-pattern **/dist/** --fix", - "lint": "prettier --check . && oxlint examples packages --ignore-pattern **/dist/**", + "format": "prettier --write . && oxlint bench examples packages --ignore-pattern **/dist/** --fix", + "lint": "prettier --check . && oxlint bench examples packages --ignore-pattern **/dist/**", "test": "npm run test -w http-message-sig -- --run && npm run test -w jsonwebkey-thumbprint -- --run && npm run test -w web-bot-auth -- --run && npm run test -w verification-workers -- --run" }, "license": "Apache-2.0", diff --git a/packages/http-message-sig/src/core.ts b/packages/http-message-sig/src/core.ts index 0fd8c14..862487d 100644 --- a/packages/http-message-sig/src/core.ts +++ b/packages/http-message-sig/src/core.ts @@ -407,9 +407,10 @@ function extractField( trailers: boolean ): string { const source = trailers ? snapshot.trailers : snapshot.fields; - const values = source - .filter((field) => field.name.toLowerCase() === name) - .map((field) => normalizeFieldValue(field.value)); + const values: string[] = []; + for (const field of source) { + if (field.name === name) values.push(normalizeFieldValue(field.value)); + } if (values.length === 0) { return fail(SignatureErrorCode.MissingField, `Missing field ${name}`); } @@ -634,7 +635,7 @@ export async function createSignature( options.parameters, options.signer.algorithm ); - const signature = await options.signer.sign(input.base.slice()); + const signature = await options.signer.sign(input.base); return Object.freeze({ signature: signatureDictionary(label, signature), signatureInput: input.signatureInput, @@ -654,10 +655,7 @@ export function createSignatureSync( options.signer.algorithm ); return Object.freeze({ - signature: signatureDictionary( - label, - options.signer.sign(input.base.slice()) - ), + signature: signatureDictionary(label, options.signer.sign(input.base)), signatureInput: input.signatureInput, }); } @@ -959,14 +957,16 @@ function assertPolicyCoverage( policy: VerificationPolicy, now: number ): void { - const present = new Set(components.map(equivalentIdentity)); - for (const required of policy.requiredComponents) { - const identity = equivalentIdentity(normalizeComponent(required)); - if (!present.has(identity)) { - fail( - SignatureErrorCode.PolicyViolation, - `Required component ${identity} is absent` - ); + if (policy.requiredComponents.length !== 0) { + const present = new Set(components.map(equivalentIdentity)); + for (const required of policy.requiredComponents) { + const identity = equivalentIdentity(normalizeComponent(required)); + if (!present.has(identity)) { + fail( + SignatureErrorCode.PolicyViolation, + `Required component ${identity} is absent` + ); + } } } for (const required of policy.requiredParameters) { @@ -1078,7 +1078,7 @@ export async function verifySignature( ); } assertAlgorithm(claimedAlgorithm, verifier.algorithm); - const valid = await verifier.verify(base.slice(), signature.slice()); + const valid = await verifier.verify(base, signature.slice()); if (!valid) { return fail( SignatureErrorCode.VerificationFailed, diff --git a/packages/http-message-sig/src/webcrypto.ts b/packages/http-message-sig/src/webcrypto.ts index e6a9434..07cba4e 100644 --- a/packages/http-message-sig/src/webcrypto.ts +++ b/packages/http-message-sig/src/webcrypto.ts @@ -38,12 +38,11 @@ export function createWebCryptoSigner(key: CryptoKey): Signer { ); } const algorithm = signatureAlgorithm(key); + const parameters = cryptoParameters(key); return Object.freeze({ algorithm, async sign(data: Uint8Array): Promise { - return new Uint8Array( - await crypto.subtle.sign(cryptoParameters(key), key, data) - ); + return new Uint8Array(await crypto.subtle.sign(parameters, key, data)); }, }); } @@ -56,10 +55,11 @@ export function createWebCryptoVerifier(key: CryptoKey): Verifier { ); } const algorithm = signatureAlgorithm(key); + const parameters = cryptoParameters(key); return Object.freeze({ algorithm, verify(data: Uint8Array, signature: Uint8Array): Promise { - return crypto.subtle.verify(cryptoParameters(key), key, signature, data); + return crypto.subtle.verify(parameters, key, signature, data); }, }); } diff --git a/packages/jsonwebkey-thumbprint/test/index.bench.ts b/packages/jsonwebkey-thumbprint/test/index.bench.ts index 269d344..77f4009 100644 --- a/packages/jsonwebkey-thumbprint/test/index.bench.ts +++ b/packages/jsonwebkey-thumbprint/test/index.bench.ts @@ -74,9 +74,9 @@ async function setupBenchmarks() { b64ToB64URL(b64ToB64NoPadding(u8ToB64(new Uint8Array(u)))); bench( `${name} jwkThumbprint`, - () => { + async () => { for (const input of inputs) { - jwkThumbprint(input, hash, decode); + await jwkThumbprint(input, hash, decode); } }, { iterations: 1000 }