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
49 changes: 41 additions & 8 deletions packages/http-message-sig/src/build.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,27 @@
import { Component, Parameters, RequestLike, ResponseLike } from "./types";
import {
Component,
Parameters,
RequestLike,
ResponseLike,
StructuredFieldComponent,
} from "./types";

export function extractStructuredFieldDictionaryHeader(
r: RequestLike | ResponseLike,
component: StructuredFieldComponent
): string {
const headerValue = extractHeader(r, component.header);
if (!headerValue) return headerValue;

const items = headerValue.split(",").map((item) => item.trim());
for (const item of items) {
const [key, ...rest] = item.split("=");
if (key === component.key) {
return rest.join("=").replace(/^"|"$/g, "");
}
}
return "";
}

export function extractHeader(
{ headers }: RequestLike | ResponseLike,
Expand Down Expand Up @@ -74,13 +97,18 @@ export function extractComponent(
}
}

const componentToString = (component: Component): string => {
if (typeof component === "string") {
return `"${component}"`.toLowerCase();
}
return `"${component.header}";key="${component.key}"`.toLocaleLowerCase();
};

export function buildSignatureInputString(
componentNames: Component[],
parameters: Parameters
): string {
const components = componentNames
.map((name) => `"${name.toLowerCase()}"`)
.join(" ");
const components = componentNames.map(componentToString).join(" ");
const values = Object.entries(parameters)
.map(([parameter, value]) => {
if (typeof value === "number") return `;${parameter}=${value}`;
Expand All @@ -99,10 +127,15 @@ export function buildSignedData(
signatureInputString: string
): string {
const parts = components.map((component) => {
const value = component.startsWith("@")
? extractComponent(request, component)
: extractHeader(request, component);
return `"${component.toLowerCase()}": ${value}`;
let value: string;
if (typeof component !== "string") {
value = extractStructuredFieldDictionaryHeader(request, component);
} else if (component.startsWith("@")) {
value = extractComponent(request, component);
} else {
value = extractHeader(request, component);
}
return `${componentToString(component)}: ${value}`;
});
parts.push(`"@signature-params": ${signatureInputString}`);
return parts.join("\n");
Expand Down
2 changes: 1 addition & 1 deletion packages/http-message-sig/src/directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export async function directoryResponseHeaders<T1 extends RequestLike>(

// TODO: consider validating the directory structure, and confirm we have one signer per key

const components: string[] = RESPONSE_COMPONENTS;
const components = RESPONSE_COMPONENTS;

const headers = new Map<string, SignatureHeaders>();

Expand Down
63 changes: 40 additions & 23 deletions packages/http-message-sig/src/parse.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import { Component, HeaderValue, Parameter, Parameters } from "./types";
import {
Component,
HeaderValue,
Parameter,
Parameters,
StructuredFieldComponent,
} from "./types";
import { decode as base64Decode } from "./base64";

function parseEntry(
headerName: string,
entry: string
): [string, string | number | true | (string | number)[]] {
): [
string,
string | number | true | (string | number | StructuredFieldComponent)[],
] {
// this is wrong. it should only split the first `=`
const equalsIndex = entry.indexOf("=");
if (equalsIndex === -1) {
Expand All @@ -19,19 +28,34 @@ function parseEntry(
if (value.match(/^".*"$/)) return [key.trim(), value.slice(1, -1)];
if (value.match(/^\d+$/)) return [key.trim(), parseInt(value)];

// TODO: this is restricted to components array. Per RFC9421, there could be more
Copy link
Contributor

Choose a reason for hiding this comment

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

A small comment to clarify value here has shape of an SFV innerlist would help - I didn't realize this was parsing e.g. foo=("a" "b";key="...") for a while.

if (value.match(/^\(.*\)$/)) {
const arr = value
.slice(1, -1)
.split(/\s+/)
.map((entry) => entry.match(/^"(.*)"$/)?.[1] ?? parseInt(entry));
const arr = value.slice(1, -1).split(/\s+/);

const res = [];
for (const item of arr) {
const match = item.match(/^"(.*)"$/);
let toPush;
if (!match) {
toPush = parseInt(item);
} else if (match[1].includes('";key="')) {
toPush = {
key: match[1].split('";key="')[1],
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: this works if key is the first parameter on the inner list, and it works for the specific case of "signature-agent";key"...." today because it's the only parameter we require.

But for cases of two or more keys, where order is not fixed, this will break. I think a proper parser of innerlist parameters for incoming requests should handle that.

header: match[1].split('";key="')[0],
};
} else {
toPush = match[1];
}
res.push(toPush);
}

if (arr.some((value) => typeof value === "number" && isNaN(value))) {
if (res.some((value) => typeof value === "number" && isNaN(value))) {
throw new Error(
`Invalid ${headerName} header. Invalid value ${key}=${value}`
);
}

return [key.trim(), arr];
return [key.trim(), res];
}

throw new Error(
Expand All @@ -43,27 +67,20 @@ function parseParametersHeader(
name: string,
header: HeaderValue
): { key: string; components: Component[]; parameters: Parameters } {
const entries = header
.toString()
const rawHeader = header.toString();
const [rawComponents, rawParameters] = rawHeader.split(/(?<=\))/, 2);
const [key, components] = parseEntry(name, rawComponents.trim()) as [
string,
Component[],
];

const entries = rawParameters
// eslint-disable-next-line security/detect-unsafe-regex
.match(/(?:[^;"]+|"[^"]+")+/g)
?.map((entry) => parseEntry(name, entry.trim()));

if (!entries) throw new Error(`Invalid ${name} header. Invalid value`);

const componentsIndex = entries.findIndex(([, value]) =>
Array.isArray(value)
);
if (componentsIndex === -1)
throw new Error(`Invalid ${name} header. Missing components`);
const [[key, components]] = entries.splice(componentsIndex, 1) as [
[string, Component[]],
];

if (entries.some(([, value]) => Array.isArray(value))) {
throw new Error(`Multiple signatures is not supported`);
}

const parameters = Object.fromEntries(entries) as Record<
Parameter,
string | number | Date
Expand Down
8 changes: 7 additions & 1 deletion packages/http-message-sig/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ export type Parameter =
| "keyid"
| string;

export interface StructuredFieldComponent {
Copy link
Contributor

Choose a reason for hiding this comment

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

The name StructuredDictionaryHeader is clearer about the shape of the component and what it applies to.

header: string;
key: string;
}

export type Component =
| "@method"
| "@target-uri"
Expand All @@ -67,7 +72,8 @@ export type Component =
| "@query-param"
| "@status"
| "@request-response"
| string;
| string
| StructuredFieldComponent;

interface StandardParameters {
expires?: Date;
Expand Down
17 changes: 17 additions & 0 deletions packages/http-message-sig/test/build.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,8 @@ describe("build", () => {
"Content-Type": "application/json",
Digest: "SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=",
"Content-Length": "18",
"Test-Structured-Field":
'one-key="random", test-key="test-value", another-key=42',
},
};

Expand Down Expand Up @@ -238,6 +240,21 @@ describe("build", () => {
);
});

it("constructs structured-field dictionary example", () => {
const components: Component[] = [
{ header: "Test-Structured-Field", key: "test-key" },
];
const data = buildSignedData(
testRequest,
components,
'("test-structured-field";key="test-key");created=1618884475;keyid="test-key-rsa-pss"'
);
expect(data).to.equal(
'"test-structured-field";key="test-key": test-value\n' +
'"@signature-params": ("test-structured-field";key="test-key");created=1618884475;keyid="test-key-rsa-pss"'
);
});

it("constructs full example", () => {
const components: Component[] = [
"Date",
Expand Down
2 changes: 1 addition & 1 deletion packages/web-bot-auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
},
"scripts": {
"build": "tsup src/index.ts src/crypto.ts --format cjs,esm --dts --clean",
"generate-test-vectors": "node --experimental-transform-types scripts/test-vectors.ts test/test_data/web_bot_auth_architecture_v1.json",
"generate-test-vectors": "npm run build && node --experimental-transform-types scripts/test-vectors.ts test/test_data/web_bot_auth_architecture_v2.json",
"prepublishOnly": "npm run build",
"test": "vitest",
"watch": "npm run build -- --watch src"
Expand Down
24 changes: 17 additions & 7 deletions packages/web-bot-auth/scripts/test-vectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
///
/// It takes one positional argument: [path] which is where the vectors should be written in JSON

const { generateNonce, signatureHeaders } = await import("../src/index.ts");
const { generateNonce, recommendedComponents, signatureHeaders } = await import(
"../dist/index.mjs"
);

const { signerFromJWK } = await import("../src/crypto.ts");
const { signerFromJWK } = await import("../dist/crypto.mjs");

const fs = await import("fs");

Expand All @@ -22,18 +24,20 @@ interface TestVector {
signature: string;
signature_input: string;
signature_agent?: string;
signature_agent_key?: string;
}

async function generateTestVectors(jwk: JsonWebKey): Promise<TestVector[]> {
const now = new Date("2025-01-01T00:00:00Z");
const created = now;
const expires = new Date(now.getTime() + 3_600_000);
const expires = new Date(now.getTime() + 3_153_600_000_000);
const signer = await signerFromJWK(jwk);

const nonce = generateNonce();
const label = "sig1";
let request = new Request(ORIGIN_URL);
const signedHeaders = await signatureHeaders(request, signer, {
components: recommendedComponents(),
created,
expires,
nonce,
Expand All @@ -42,10 +46,14 @@ async function generateTestVectors(jwk: JsonWebKey): Promise<TestVector[]> {

const nonceWithAgent = generateNonce();
const labelWithAgent = "sig2";
const signatureAgentKey = "agent2";
request = new Request(ORIGIN_URL, {
headers: { "Signature-Agent": JSON.stringify(SIGNATURE_AGENT_HEADER) },
headers: {
"Signature-Agent": `${signatureAgentKey}="${SIGNATURE_AGENT_HEADER}"`,
},
});
const signedHeadersWithAgent = await signatureHeaders(request, signer, {
components: recommendedComponents(signatureAgentKey),
created,
expires,
nonce: nonceWithAgent,
Expand Down Expand Up @@ -73,6 +81,7 @@ async function generateTestVectors(jwk: JsonWebKey): Promise<TestVector[]> {
signature: signedHeadersWithAgent["Signature"],
signature_input: signedHeadersWithAgent["Signature-Input"],
signature_agent: request.headers.get("Signature-Agent"),
signature_agent_key: signatureAgentKey,
},
];
}
Expand Down Expand Up @@ -110,10 +119,11 @@ NOTE: '\\' line wrapping per RFC 8792
`);
console.log(`"@authority": ${new URL(vector.target_url).host}`);
if (vector.signature_agent) {
console.log(`"signature-agent": ${vector.signature_agent}`);
const split = vector.signature_agent.split("=");
console.log(`"signature-agent";key="${split[0]}": ${split[1]}`);
}
console.log(
`"@signature-params": ${vector.signature_input.slice(`${vector.label}=`.length).replaceAll(";", "\\\n ;")}`
`"@signature-params": ${vector.signature_input.slice(`${vector.label}=`.length).replaceAll(";", "\\\n ;").replaceAll("\\\n ;key=", ";key=")}`
);
console.log("");

Expand All @@ -125,7 +135,7 @@ NOTE: '\\' line wrapping per RFC 8792
console.log(`Signature-Agent: ${vector.signature_agent}`);
}
console.log(
`Signature-Input: ${vector.signature_input.replaceAll(";", "\\\n ;")}`
`Signature-Input: ${vector.signature_input.replaceAll(";", "\\\n ;").replaceAll("\\\n ;key=", ";key=")}`
);
console.log(`Signature: ${vector.signature}`);
console.log("");
Expand Down
Loading