Skip to content

Commit 28a3d83

Browse files
committed
Support draft-directory-04 with sf-dictionary signature-agent
This commits adds support for sf-dioctionary headers in http-message-sig, and paired signature-agent as a dictionary format. This is made to be backward compatible: old test vectors still pass. The implementation of sf-dictionary is primitive, and likely does not pass all tests for [RFC 8941](https://www.rfc-editor.org/rfc/rfc8941.html). This is acceptable for now. We _could_ publish this as an alpha. The new test vectors are added in thibmeu/http-message-signatures-directory#79, and have a corresponding json [web_bot_auth_architecture_v2.json](./packages/web-bot-auth/test/test_data/web_bot_auth_architecture_v2.json). They can be imported by other implementations.
1 parent c607cdd commit 28a3d83

10 files changed

Lines changed: 237 additions & 63 deletions

File tree

packages/http-message-sig/src/build.ts

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,27 @@
1-
import { Component, Parameters, RequestLike, ResponseLike } from "./types";
1+
import {
2+
Component,
3+
Parameters,
4+
RequestLike,
5+
ResponseLike,
6+
StructuredFieldComponent,
7+
} from "./types";
8+
9+
export function extractStructuredFieldDictionaryHeader(
10+
r: RequestLike | ResponseLike,
11+
component: StructuredFieldComponent
12+
): string {
13+
const headerValue = extractHeader(r, component.header);
14+
if (!headerValue) return headerValue;
15+
16+
const items = headerValue.split(",").map((item) => item.trim());
17+
for (const item of items) {
18+
const [key, ...rest] = item.split("=");
19+
if (key === component.key) {
20+
return rest.join("=").replace(/^"|"$/g, "");
21+
}
22+
}
23+
return "";
24+
}
225

326
export function extractHeader(
427
{ headers }: RequestLike | ResponseLike,
@@ -74,13 +97,18 @@ export function extractComponent(
7497
}
7598
}
7699

100+
const componentToString = (component: Component): string => {
101+
if (typeof component === "string") {
102+
return `"${component}"`.toLowerCase();
103+
}
104+
return `"${component.header}";key="${component.key}"`.toLocaleLowerCase();
105+
};
106+
77107
export function buildSignatureInputString(
78108
componentNames: Component[],
79109
parameters: Parameters
80110
): string {
81-
const components = componentNames
82-
.map((name) => `"${name.toLowerCase()}"`)
83-
.join(" ");
111+
const components = componentNames.map(componentToString).join(" ");
84112
const values = Object.entries(parameters)
85113
.map(([parameter, value]) => {
86114
if (typeof value === "number") return `;${parameter}=${value}`;
@@ -99,10 +127,15 @@ export function buildSignedData(
99127
signatureInputString: string
100128
): string {
101129
const parts = components.map((component) => {
102-
const value = component.startsWith("@")
103-
? extractComponent(request, component)
104-
: extractHeader(request, component);
105-
return `"${component.toLowerCase()}": ${value}`;
130+
let value: string;
131+
if (typeof component !== "string") {
132+
value = extractStructuredFieldDictionaryHeader(request, component);
133+
} else if (component.startsWith("@")) {
134+
value = extractComponent(request, component);
135+
} else {
136+
value = extractHeader(request, component);
137+
}
138+
return `${componentToString(component)}: ${value}`;
106139
});
107140
parts.push(`"@signature-params": ${signatureInputString}`);
108141
return parts.join("\n");

packages/http-message-sig/src/directory.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export async function directoryResponseHeaders<T1 extends RequestLike>(
2020

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

23-
const components: string[] = RESPONSE_COMPONENTS;
23+
const components = RESPONSE_COMPONENTS;
2424

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

packages/http-message-sig/src/parse.ts

Lines changed: 40 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
1-
import { Component, HeaderValue, Parameter, Parameters } from "./types";
1+
import {
2+
Component,
3+
HeaderValue,
4+
Parameter,
5+
Parameters,
6+
StructuredFieldComponent,
7+
} from "./types";
28
import { decode as base64Decode } from "./base64";
39

410
function parseEntry(
511
headerName: string,
612
entry: string
7-
): [string, string | number | true | (string | number)[]] {
13+
): [
14+
string,
15+
string | number | true | (string | number | StructuredFieldComponent)[],
16+
] {
817
// this is wrong. it should only split the first `=`
918
const equalsIndex = entry.indexOf("=");
1019
if (equalsIndex === -1) {
@@ -19,19 +28,34 @@ function parseEntry(
1928
if (value.match(/^".*"$/)) return [key.trim(), value.slice(1, -1)];
2029
if (value.match(/^\d+$/)) return [key.trim(), parseInt(value)];
2130

31+
// TODO: this is restricted to components array. Per RFC9421, there could be more
2232
if (value.match(/^\(.*\)$/)) {
23-
const arr = value
24-
.slice(1, -1)
25-
.split(/\s+/)
26-
.map((entry) => entry.match(/^"(.*)"$/)?.[1] ?? parseInt(entry));
33+
const arr = value.slice(1, -1).split(/\s+/);
34+
35+
const res = [];
36+
for (const item of arr) {
37+
const match = item.match(/^"(.*)"$/);
38+
let toPush;
39+
if (!match) {
40+
toPush = parseInt(item);
41+
} else if (match[1].includes('";key="')) {
42+
toPush = {
43+
key: match[1].split('";key="')[1],
44+
header: match[1].split('";key="')[0],
45+
};
46+
} else {
47+
toPush = match[1];
48+
}
49+
res.push(toPush);
50+
}
2751

28-
if (arr.some((value) => typeof value === "number" && isNaN(value))) {
52+
if (res.some((value) => typeof value === "number" && isNaN(value))) {
2953
throw new Error(
3054
`Invalid ${headerName} header. Invalid value ${key}=${value}`
3155
);
3256
}
3357

34-
return [key.trim(), arr];
58+
return [key.trim(), res];
3559
}
3660

3761
throw new Error(
@@ -43,27 +67,20 @@ function parseParametersHeader(
4367
name: string,
4468
header: HeaderValue
4569
): { key: string; components: Component[]; parameters: Parameters } {
46-
const entries = header
47-
.toString()
70+
const rawHeader = header.toString();
71+
const [rawComponents, rawParameters] = rawHeader.split(/(?<=\))/, 2);
72+
const [key, components] = parseEntry(name, rawComponents.trim()) as [
73+
string,
74+
Component[],
75+
];
76+
77+
const entries = rawParameters
4878
// eslint-disable-next-line security/detect-unsafe-regex
4979
.match(/(?:[^;"]+|"[^"]+")+/g)
5080
?.map((entry) => parseEntry(name, entry.trim()));
5181

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

54-
const componentsIndex = entries.findIndex(([, value]) =>
55-
Array.isArray(value)
56-
);
57-
if (componentsIndex === -1)
58-
throw new Error(`Invalid ${name} header. Missing components`);
59-
const [[key, components]] = entries.splice(componentsIndex, 1) as [
60-
[string, Component[]],
61-
];
62-
63-
if (entries.some(([, value]) => Array.isArray(value))) {
64-
throw new Error(`Multiple signatures is not supported`);
65-
}
66-
6784
const parameters = Object.fromEntries(entries) as Record<
6885
Parameter,
6986
string | number | Date

packages/http-message-sig/src/types.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ export type Parameter =
5656
| "keyid"
5757
| string;
5858

59+
export interface StructuredFieldComponent {
60+
header: string;
61+
key: string;
62+
}
63+
5964
export type Component =
6065
| "@method"
6166
| "@target-uri"
@@ -67,7 +72,8 @@ export type Component =
6772
| "@query-param"
6873
| "@status"
6974
| "@request-response"
70-
| string;
75+
| string
76+
| StructuredFieldComponent;
7177

7278
interface StandardParameters {
7379
expires?: Date;

packages/http-message-sig/test/build.spec.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,8 @@ describe("build", () => {
209209
"Content-Type": "application/json",
210210
Digest: "SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=",
211211
"Content-Length": "18",
212+
"Test-Structured-Field":
213+
'one-key="random", test-key="test-value", another-key=42',
212214
},
213215
};
214216

@@ -238,6 +240,21 @@ describe("build", () => {
238240
);
239241
});
240242

243+
it("constructs structured-field dictionary example", () => {
244+
const components: Component[] = [
245+
{ header: "Test-Structured-Field", key: "test-key" },
246+
];
247+
const data = buildSignedData(
248+
testRequest,
249+
components,
250+
'("test-structured-field";key="test-key");created=1618884475;keyid="test-key-rsa-pss"'
251+
);
252+
expect(data).to.equal(
253+
'"test-structured-field";key="test-key": test-value\n' +
254+
'"@signature-params": ("test-structured-field";key="test-key");created=1618884475;keyid="test-key-rsa-pss"'
255+
);
256+
});
257+
241258
it("constructs full example", () => {
242259
const components: Component[] = [
243260
"Date",

packages/web-bot-auth/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
},
2424
"scripts": {
2525
"build": "tsup src/index.ts src/crypto.ts --format cjs,esm --dts --clean",
26-
"generate-test-vectors": "node --experimental-transform-types scripts/test-vectors.ts test/test_data/web_bot_auth_architecture_v1.json",
26+
"generate-test-vectors": "npm run build && node --experimental-transform-types scripts/test-vectors.ts test/test_data/web_bot_auth_architecture_v2.json",
2727
"prepublishOnly": "npm run build",
2828
"test": "vitest",
2929
"watch": "npm run build -- --watch src"

packages/web-bot-auth/scripts/test-vectors.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
///
44
/// It takes one positional argument: [path] which is where the vectors should be written in JSON
55

6-
const { generateNonce, signatureHeaders } = await import("../src/index.ts");
6+
const { generateNonce, recommendedComponents, signatureHeaders } = await import(
7+
"../dist/index.mjs"
8+
);
79

8-
const { signerFromJWK } = await import("../src/crypto.ts");
10+
const { signerFromJWK } = await import("../dist/crypto.mjs");
911

1012
const fs = await import("fs");
1113

@@ -22,18 +24,20 @@ interface TestVector {
2224
signature: string;
2325
signature_input: string;
2426
signature_agent?: string;
27+
signature_agent_key?: string;
2528
}
2629

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

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

4347
const nonceWithAgent = generateNonce();
4448
const labelWithAgent = "sig2";
49+
const signatureAgentKey = "agent2";
4550
request = new Request(ORIGIN_URL, {
46-
headers: { "Signature-Agent": JSON.stringify(SIGNATURE_AGENT_HEADER) },
51+
headers: {
52+
"Signature-Agent": `${signatureAgentKey}="${SIGNATURE_AGENT_HEADER}"`,
53+
},
4754
});
4855
const signedHeadersWithAgent = await signatureHeaders(request, signer, {
56+
components: recommendedComponents(signatureAgentKey),
4957
created,
5058
expires,
5159
nonce: nonceWithAgent,
@@ -73,6 +81,7 @@ async function generateTestVectors(jwk: JsonWebKey): Promise<TestVector[]> {
7381
signature: signedHeadersWithAgent["Signature"],
7482
signature_input: signedHeadersWithAgent["Signature-Input"],
7583
signature_agent: request.headers.get("Signature-Agent"),
84+
signature_agent_key: signatureAgentKey,
7685
},
7786
];
7887
}

packages/web-bot-auth/src/index.ts

Lines changed: 25 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,21 +18,14 @@ export { helpers } from "./crypto";
1818

1919
export const HTTP_MESSAGE_SIGNAGURE_TAG = "web-bot-auth";
2020
export const SIGNATURE_AGENT_HEADER = "signature-agent";
21-
export const REQUEST_COMPONENTS_WITHOUT_SIGNATURE_AGENT: httpsig.Component[] = [
22-
"@authority",
23-
];
24-
export const REQUEST_COMPONENTS: httpsig.Component[] = [
25-
"@authority",
26-
SIGNATURE_AGENT_HEADER,
27-
];
2821
export const NONCE_LENGTH_IN_BYTES = 64;
2922

3023
export interface SignatureParams {
24+
components: httpsig.Component[];
3125
created: Date;
3226
expires: Date;
3327
nonce?: string;
3428
key?: string;
35-
components?: httpsig.Component[];
3629
}
3730

3831
export interface VerificationParams {
@@ -57,6 +50,18 @@ export function validateNonce(nonce: string): boolean {
5750
}
5851
}
5952

53+
export function recommendedComponents(
54+
signatureAgentKey?: string
55+
): httpsig.Component[] {
56+
if (signatureAgentKey) {
57+
return [
58+
"@authority",
59+
{ header: SIGNATURE_AGENT_HEADER, key: signatureAgentKey },
60+
];
61+
}
62+
return ["@authority"];
63+
}
64+
6065
function getSigningOptions<
6166
T extends httpsig.RequestLike | httpsig.ResponseLike,
6267
>(
@@ -76,25 +81,21 @@ function getSigningOptions<
7681
}
7782
}
7883
const signatureAgent = httpsig.extractHeader(message, SIGNATURE_AGENT_HEADER);
79-
let components: string[];
80-
if (!params.components) {
81-
// `extractHeader` returns "" instead of throwing or null when the header does not exist
82-
if (!signatureAgent) {
83-
components = REQUEST_COMPONENTS_WITHOUT_SIGNATURE_AGENT;
84-
} else {
85-
components = REQUEST_COMPONENTS;
86-
}
87-
} else {
88-
if (signatureAgent && components.indexOf("SIGNATURE_AGENT_HEADER") === -1) {
89-
throw new Error(
90-
`${SIGNATURE_AGENT_HEADER} is required in params.component when included as a header param`
91-
);
92-
}
93-
components = params.components;
84+
if (
85+
signatureAgent &&
86+
!params.components.find(
87+
(c) =>
88+
(typeof c !== "string" && c.header === SIGNATURE_AGENT_HEADER) ||
89+
c === SIGNATURE_AGENT_HEADER
90+
)
91+
) {
92+
throw new Error(
93+
`${SIGNATURE_AGENT_HEADER} is required in params.component when included as a header param`
94+
);
9495
}
9596

9697
return {
97-
components,
98+
components: params.components,
9899
created: params.created,
99100
expires: params.expires,
100101
nonce,

0 commit comments

Comments
 (0)