Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
dist/
node_modules/
target/
target/
bench/.trace/
27 changes: 27 additions & 0 deletions bench/README.md
Original file line number Diff line number Diff line change
@@ -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.
103 changes: 103 additions & 0 deletions bench/fixtures.mjs
Original file line number Diff line number Diff line change
@@ -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),
});
19 changes: 19 additions & 0 deletions bench/signatures.bench.ts
Original file line number Diff line number Diff line change
@@ -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);
});
82 changes: 82 additions & 0 deletions bench/trace.mjs
Original file line number Diff line number Diff line change
@@ -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)`
);
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
34 changes: 17 additions & 17 deletions packages/http-message-sig/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no toLowerCase check needed?

}
if (values.length === 0) {
return fail(SignatureErrorCode.MissingField, `Missing field ${name}`);
}
Expand Down Expand Up @@ -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,
Expand All @@ -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,
});
}
Expand Down Expand Up @@ -959,14 +957,16 @@ function assertPolicyCoverage<V extends Verifier>(
policy: VerificationPolicy<V>,
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) {
Expand Down Expand Up @@ -1078,7 +1078,7 @@ export async function verifySignature<V extends Verifier>(
);
}
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,
Expand Down
8 changes: 4 additions & 4 deletions packages/http-message-sig/src/webcrypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uint8Array> {
return new Uint8Array(
await crypto.subtle.sign(cryptoParameters(key), key, data)
);
return new Uint8Array(await crypto.subtle.sign(parameters, key, data));
},
});
}
Expand All @@ -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<boolean> {
return crypto.subtle.verify(cryptoParameters(key), key, signature, data);
return crypto.subtle.verify(parameters, key, signature, data);
},
});
}
4 changes: 2 additions & 2 deletions packages/jsonwebkey-thumbprint/test/index.bench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down