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
58 changes: 49 additions & 9 deletions packages/http-message-sig/src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,27 @@ import {
RequestLike,
ResponseLike,
ResponseRequestPair,
StructuredFieldComponent,
} from "./types";
import { serializeItem } from "structured-headers";

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,
header: string
Expand Down Expand Up @@ -81,12 +99,22 @@ export function extractComponent(
}
}

export function isStructuredFieldComponent(
component: Component
): component is StructuredFieldComponent {
return (component as StructuredFieldComponent).header !== undefined;
}

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

if (isStructuredFieldComponent(cwp)) {
return `"${cwp.header.toLowerCase()}";key="${cwp.key}"`;
}

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

export function isRawMessage(
Expand Down Expand Up @@ -154,12 +182,24 @@ 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 (isStructuredFieldComponent(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
11 changes: 11 additions & 0 deletions packages/http-message-sig/src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
HeaderValue,
Parameter,
Parameters,
StructuredFieldComponent,
} from "./types";
import { decode as base64Decode } from "./base64";
import { parseDictionary, isInnerList } from "structured-headers";
Expand Down Expand Up @@ -66,6 +67,16 @@ function parseSfvDictionary(
}
}

if (
componentParams.has("key") &&
typeof componentParams.get("key") === "string"
) {
return {
header: component,
key: componentParams.get("key") as string,
} as StructuredFieldComponent;
}

return {
name: component,
parameters: componentParams as ComponentParameters,
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,11 @@ export type Parameter =
| "keyid"
| string;

export interface StructuredFieldComponent {
header: string;
key: string;
}

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

export interface ComponentWithParameters {
name: string;
Expand Down Expand Up @@ -104,6 +111,7 @@ export type SignOptions = StandardParameters & {
[name: Parameter]:
| Component[]
| ComponentWithParameters[]
| StructuredFieldComponent[]
| Signer
| string
| number
Expand All @@ -120,6 +128,7 @@ export type SignSyncOptions = StandardParameters & {
[name: Parameter]:
| Component[]
| ComponentWithParameters[]
| StructuredFieldComponent[]
| SignerSync
| string
| number
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
27 changes: 24 additions & 3 deletions packages/web-bot-auth/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,18 @@ export function validateNonce(nonce: string): boolean {
}
}

export function recommendedComponents(
signatureAgentKey?: string
): httpsig.Component[] {
if (signatureAgentKey) {
return [
"@authority",
{ header: SIGNATURE_AGENT_HEADER, key: signatureAgentKey },
];
}
return ["@authority"];
}

function getSigningOptions<
T extends
| httpsig.RequestLike
Expand Down Expand Up @@ -91,9 +103,18 @@ function getSigningOptions<
components = REQUEST_COMPONENTS;
}
} else {
if (signatureAgent && !params.components.some(c =>
typeof c === 'string' ? c === SIGNATURE_AGENT_HEADER : c.name === SIGNATURE_AGENT_HEADER
)) {
if (
signatureAgent &&
!params.components.some((c) => {
if (typeof c === "string") {
return c === SIGNATURE_AGENT_HEADER;
}
if ("header" in c) {
return c.header === SIGNATURE_AGENT_HEADER;
}
return c.name === SIGNATURE_AGENT_HEADER;
})
) {
throw new Error(
`${SIGNATURE_AGENT_HEADER} is required in params.components when included as a header param`
);
Expand Down
11 changes: 10 additions & 1 deletion packages/web-bot-auth/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,15 @@ import {
NONCE_LENGTH_IN_BYTES,
SIGNATURE_AGENT_HEADER,
verify,
recommendedComponents,
} from "../src/index";
import { signerFromJWK, verifierFromJWK } from "../src/crypto";
import { b64Tou8, u8ToB64 } from "../src/base64";

import vectors from "./test_data/web_bot_auth_architecture_v1.json";
import vectors1 from "./test_data/web_bot_auth_architecture_v1.json";
import vector2 from "./test_data/web_bot_auth_architecture_v2.json";

const vectors = [...vectors1, ...vector2];
type Vectors = (typeof vectors)[number];

describe.each(vectors)("Web-bot-auth-ed25519-Vector-%#", (v: Vectors) => {
Expand All @@ -24,6 +28,11 @@ describe.each(vectors)("Web-bot-auth-ed25519-Vector-%#", (v: Vectors) => {
}
const request = new Request(v.target_url, { headers });
const signedHeaders = await signatureHeaders(request, signer, {
components: Object.hasOwnProperty.call(v, "signature_agent_key")
? recommendedComponents(v["signature_agent_key"])
: v.signature_agent
? ["@authority", "signature-agent"]
: recommendedComponents(),
created: new Date(v.created_ms),
expires: new Date(v.expires_ms),
nonce: v.nonce,
Expand Down
Loading
Loading