Skip to content
Merged
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
100 changes: 88 additions & 12 deletions packages/http-message-sig/src/build.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,43 @@
import {
Component,
ComponentParameters,
ComponentWithParameters,
Parameters,
RequestLike,
ResponseLike,
ResponseRequestPair,
StructuredFieldDictionaryComponent,
} from "./types";
import { serializeItem } from "structured-headers";
import {
isInnerList,
parseDictionary,
serializeInnerList,
serializeItem,
} from "structured-headers";

/**
* Extract a value from a dictionary-style header by key.
*
* The selected member value is serialized per RFC 8941, as required by
* RFC 9421 section 2.1.2.
*/
export function extractStructuredFieldDictionaryHeader(
r: RequestLike | ResponseLike,
component: StructuredFieldDictionaryComponent
): string {
const headerValue = extractHeader(r, component.header);
if (!headerValue) return headerValue;

const dictionary = parseDictionary(headerValue);
const item = dictionary.get(component.key);
if (!item) {
throw new Error(
`Header ${component.header} does not contain dictionary key ${component.key}`
);
}

return isInnerList(item) ? serializeInnerList(item) : serializeItem(item);
}

export function extractHeader(
{ headers }: RequestLike | ResponseLike,
Expand Down Expand Up @@ -81,12 +112,44 @@ export function extractComponent(
}
}

export function isStructuredFieldDictionaryComponent(
component: Component
): component is StructuredFieldDictionaryComponent {
return typeof component === "object" && "header" in component;
}

function structuredFieldComponentParameters(
cwp: StructuredFieldDictionaryComponent
): ComponentParameters {
if (!cwp.parameters) {
return new Map([["key", cwp.key]]);
}

const key = cwp.parameters.get("key");
if (key === cwp.key) {
return cwp.parameters;
}

if (key !== undefined) {
throw new Error(
`Structured field component key mismatch ${key.toString()} !== ${cwp.key}`
);
}

return new Map([["key", cwp.key], ...cwp.parameters]);
}

export function serializeComponent(cwp: Component): string {
if (componentHasParameters(cwp)) {
return serializeItem(`${cwp.name.toLowerCase()}`, cwp.parameters);
if (typeof cwp === "string") {
return `"${cwp.toLowerCase()}"`;
}

return `"${cwp.toLowerCase()}"`;
if (isStructuredFieldDictionaryComponent(cwp)) {
const parameters = structuredFieldComponentParameters(cwp);
return serializeItem(`${cwp.header.toLowerCase()}`, parameters);
}

return serializeItem(`${cwp.name.toLowerCase()}`, cwp.parameters);
}

export function isRawMessage(
Expand All @@ -100,8 +163,12 @@ export function isRawMessage(

export function componentHasParameters(
component: Component
): component is ComponentWithParameters {
return (component as ComponentWithParameters).parameters !== undefined;
): component is ComponentWithParameters | StructuredFieldDictionaryComponent {
return (
typeof component === "object" &&
"parameters" in component &&
component.parameters !== undefined
);
}

export function resolveMessageKind(
Expand Down Expand Up @@ -154,12 +221,21 @@ export function buildSignedData(
): string {
const parts = components.map((component) => {
const messageToUse = resolveMessageKind(message, component);
const componentName = componentHasParameters(component)
? component.name
: component;
const value = componentName.startsWith("@")
? extractComponent(messageToUse, componentName)
: extractHeader(messageToUse, componentName);
let value: string;

if (typeof component === "string") {
value = component.startsWith("@")
? extractComponent(messageToUse, component)
: extractHeader(messageToUse, component);
} else if (isStructuredFieldDictionaryComponent(component)) {
value = extractStructuredFieldDictionaryHeader(messageToUse, component);
} else {
const componentName = component.name;
value = componentName.startsWith("@")
? extractComponent(messageToUse, componentName)
: extractHeader(messageToUse, componentName);
}

return `${serializeComponent(component)}: ${value}`;
});
parts.push(`"@signature-params": ${signatureInputString}`);
Expand Down
18 changes: 17 additions & 1 deletion packages/http-message-sig/src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ function parseSfvDictionary(
throw new Error(`Invalid ${name} header. Missing components`);
}

// innerlist is [Item[], Map] where each Item is [string, Map<string, string | boolean>]
const [cwp, params] = innerlist;

const parameters: Parameters = Object.fromEntries(params) as Record<
Expand All @@ -58,17 +59,32 @@ function parseSfvDictionary(
return component;
}

const parameters: ComponentParameters = new Map();
let key: string | undefined;
for (const [paramName, paramValue] of componentParams.entries()) {
if (typeof paramValue !== "string" && typeof paramValue !== "boolean") {
throw new Error(
`Failed to parse parameter ${paramName} on ${component}: type is neither string nor boolean`
);
}

parameters.set(paramName, paramValue);
if (paramName === "key" && typeof paramValue === "string") {
key = paramValue;
}
}

if (key !== undefined) {
return {
header: component,
key,
parameters,
};
}

return {
name: component,
parameters: componentParams as ComponentParameters,
parameters,
};
});

Expand Down
11 changes: 10 additions & 1 deletion packages/http-message-sig/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ export type Parameter =
| "keyid"
| string;

export interface StructuredFieldDictionaryComponent {
header: string;
key: string;
parameters?: ComponentParameters;
}

export type Component =
| "@method"
| "@target-uri"
Expand All @@ -73,7 +79,8 @@ export type Component =
| "@query-param"
| "@status"
| string
| ComponentWithParameters;
| ComponentWithParameters
| StructuredFieldDictionaryComponent;

export interface ComponentWithParameters {
name: string;
Expand Down Expand Up @@ -104,6 +111,7 @@ export type SignOptions = StandardParameters & {
[name: Parameter]:
| Component[]
| ComponentWithParameters[]
| StructuredFieldDictionaryComponent[]
| Signer
| string
| number
Expand All @@ -120,6 +128,7 @@ export type SignSyncOptions = StandardParameters & {
[name: Parameter]:
| Component[]
| ComponentWithParameters[]
| StructuredFieldDictionaryComponent[]
| SignerSync
| string
| number
Expand Down
62 changes: 62 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,66 @@ 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 structured-field dictionary value with a comma", () => {
const request: RequestLike = {
...testRequest,
headers: {
...testRequest.headers,
"Test-Structured-Field": 'test-key="test,value", other-key="other"',
},
};
const components: Component[] = [
{ header: "Test-Structured-Field", key: "test-key" },
];
const data = buildSignedData(
request,
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 structured-field dictionary with req parameter", () => {
const response: ResponseLike = {
status: 200,
headers: {},
};
const components: Component[] = [
{
header: "Test-Structured-Field",
key: "test-key",
parameters: new Map([["req", true]]),
},
];
const data = buildSignedData(
{ request: testRequest, response },
components,
'("test-structured-field";key="test-key";req);created=1618884475;keyid="test-key-rsa-pss"'
);
expect(data).to.equal(
'"test-structured-field";key="test-key";req: "test-value"\n' +
'"@signature-params": ("test-structured-field";key="test-key";req);created=1618884475;keyid="test-key-rsa-pss"'
);
});

it("constructs full example", () => {
const components: Component[] = [
"Date",
Expand Down
24 changes: 24 additions & 0 deletions packages/http-message-sig/test/parse.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,30 @@ describe("parse.ts", () => {
});
});

it("should parse a structured-field component with req", () => {
const header =
'sig1=("@status" "signature-agent";key="agent2";req);created=1618884475';
const result = parseSignatureInputHeader(header);

expect(result).to.deep.equal({
key: "sig1",
components: [
"@status",
{
header: "signature-agent",
key: "agent2",
parameters: new Map([
["key", "agent2"],
["req", true],
]),
},
],
parameters: {
created: new Date(1618884475 * 1000),
},
});
});

it("should throw an error on an invalid components string", () => {
const header = "sig1=(@method, @path, @authority, digest);invalid=foo";
expect(() => parseSignatureInputHeader(header)).to.throw(
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
Loading
Loading