hbsig 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cjs/bin_to_str.js +44 -0
- package/cjs/collect-body-keys.js +470 -0
- package/cjs/encode-array-item.js +110 -0
- package/cjs/encode-utils.js +236 -0
- package/cjs/encode.js +1318 -0
- package/cjs/erl_json.js +317 -0
- package/cjs/erl_str.js +1037 -0
- package/cjs/flat.js +222 -0
- package/cjs/http-message-signatures/httpbis.js +489 -0
- package/cjs/http-message-signatures/index.js +25 -0
- package/cjs/http-message-signatures/structured-header.js +129 -0
- package/cjs/httpsig.js +716 -0
- package/cjs/httpsig2.js +1160 -0
- package/cjs/id.js +470 -0
- package/cjs/index.js +63 -0
- package/cjs/send.js +194 -0
- package/cjs/signer-utils.js +617 -0
- package/cjs/signer.js +606 -0
- package/cjs/structured.js +296 -0
- package/cjs/test.js +27 -0
- package/cjs/utils.js +42 -0
- package/esm/bin_to_str.js +46 -0
- package/esm/collect-body-keys.js +436 -0
- package/esm/encode-array-item.js +112 -0
- package/esm/encode-utils.js +185 -0
- package/esm/encode.js +1219 -0
- package/esm/erl_json.js +289 -0
- package/esm/erl_str.js +1139 -0
- package/esm/flat.js +196 -0
- package/esm/http-message-signatures/httpbis.js +438 -0
- package/esm/http-message-signatures/index.js +4 -0
- package/esm/http-message-signatures/structured-header.js +105 -0
- package/esm/httpsig.js +658 -0
- package/esm/httpsig2.js +1097 -0
- package/esm/id.js +459 -0
- package/esm/index.js +4 -0
- package/esm/package.json +3 -0
- package/esm/send.js +124 -0
- package/esm/signer-utils.js +494 -0
- package/esm/signer.js +452 -0
- package/esm/structured.js +269 -0
- package/esm/test.js +6 -0
- package/esm/utils.js +28 -0
- package/package.json +28 -0
package/esm/httpsig2.js
ADDED
|
@@ -0,0 +1,1097 @@
|
|
|
1
|
+
import { trim } from "ramda"
|
|
2
|
+
import { decodeSigInput } from "./signer-utils.js"
|
|
3
|
+
import base64url from "base64url"
|
|
4
|
+
import { toAddr } from "./utils.js"
|
|
5
|
+
/**
|
|
6
|
+
* Get multipart boundary from content-type header
|
|
7
|
+
*/
|
|
8
|
+
const getBoundary = http => {
|
|
9
|
+
const ctype = http.headers["content-type"]
|
|
10
|
+
if (ctype && /^multipart\/form-data;/.test(trim(ctype))) {
|
|
11
|
+
for (const v of trim(ctype).split(";").slice(1)) {
|
|
12
|
+
const sp2 = v.split("=")
|
|
13
|
+
if (trim(sp2[0]) === "boundary") return trim(sp2[1]).replace(/"/g, "")
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Extract specified components from HTTP message
|
|
21
|
+
* @param {Object} http - HTTP message object with headers and optional body
|
|
22
|
+
* @param {Array} components - Array of component names to extract
|
|
23
|
+
* @returns {Object} Extracted components with their values
|
|
24
|
+
*/
|
|
25
|
+
const extract = (http, components) => {
|
|
26
|
+
const extracted = {}
|
|
27
|
+
const needsBody = components.some(
|
|
28
|
+
c => c.replace(/"/g, "").toLowerCase() === "content-digest"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
// First extract ao-types if it's signed
|
|
32
|
+
const hasAoTypes = components.some(
|
|
33
|
+
c => c.replace(/"/g, "").toLowerCase() === "ao-types"
|
|
34
|
+
)
|
|
35
|
+
if (hasAoTypes) {
|
|
36
|
+
const aoTypes = http.headers["ao-types"] || http.headers["Ao-Types"]
|
|
37
|
+
if (aoTypes) {
|
|
38
|
+
extracted["ao-types"] = aoTypes
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Extract ao-ids if it's signed
|
|
43
|
+
const hasAoIds = components.some(
|
|
44
|
+
c => c.replace(/"/g, "").toLowerCase() === "ao-ids"
|
|
45
|
+
)
|
|
46
|
+
if (hasAoIds) {
|
|
47
|
+
const aoIds = http.headers["ao-ids"] || http.headers["Ao-Ids"]
|
|
48
|
+
if (aoIds) {
|
|
49
|
+
extracted["ao-ids"] = aoIds
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (const component of components) {
|
|
54
|
+
const cleanComponent = component.replace(/"/g, "")
|
|
55
|
+
|
|
56
|
+
if (cleanComponent.startsWith("@")) {
|
|
57
|
+
// Handle derived components
|
|
58
|
+
switch (cleanComponent) {
|
|
59
|
+
case "@method":
|
|
60
|
+
extracted[cleanComponent] = http.method || "GET"
|
|
61
|
+
break
|
|
62
|
+
case "@target-uri":
|
|
63
|
+
extracted[cleanComponent] = http.url || ""
|
|
64
|
+
break
|
|
65
|
+
case "@authority":
|
|
66
|
+
extracted[cleanComponent] = http.headers.host || ""
|
|
67
|
+
break
|
|
68
|
+
case "@scheme":
|
|
69
|
+
if (http.url) {
|
|
70
|
+
const url = new URL(http.url)
|
|
71
|
+
extracted[cleanComponent] = url.protocol.replace(":", "")
|
|
72
|
+
}
|
|
73
|
+
break
|
|
74
|
+
case "@request-target":
|
|
75
|
+
if (http.url) {
|
|
76
|
+
const url = new URL(http.url)
|
|
77
|
+
extracted[cleanComponent] = url.pathname + url.search
|
|
78
|
+
}
|
|
79
|
+
break
|
|
80
|
+
case "@path":
|
|
81
|
+
if (http.url) {
|
|
82
|
+
const url = new URL(http.url)
|
|
83
|
+
extracted[cleanComponent] = url.pathname
|
|
84
|
+
}
|
|
85
|
+
break
|
|
86
|
+
case "@query":
|
|
87
|
+
if (http.url) {
|
|
88
|
+
const url = new URL(http.url)
|
|
89
|
+
extracted[cleanComponent] = url.search
|
|
90
|
+
}
|
|
91
|
+
break
|
|
92
|
+
case "@status":
|
|
93
|
+
extracted[cleanComponent] = String(
|
|
94
|
+
http.status || http["@status"] || ""
|
|
95
|
+
)
|
|
96
|
+
break
|
|
97
|
+
case "@query-param":
|
|
98
|
+
// This would need additional parsing logic for specific query parameters
|
|
99
|
+
break
|
|
100
|
+
}
|
|
101
|
+
} else {
|
|
102
|
+
// Handle regular headers - try both exact case and lowercase
|
|
103
|
+
const headerValue =
|
|
104
|
+
http.headers[cleanComponent] ||
|
|
105
|
+
http.headers[cleanComponent.toLowerCase()]
|
|
106
|
+
if (headerValue !== null && headerValue !== undefined) {
|
|
107
|
+
extracted[cleanComponent] = headerValue
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// If content-digest is signed, we need to include the body
|
|
113
|
+
if (needsBody && http.body !== undefined) {
|
|
114
|
+
extracted["body"] = http.body
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Add flag if body is needed but missing
|
|
118
|
+
if (needsBody && http.body === undefined) {
|
|
119
|
+
extracted["__bodyRequired__"] = true
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return extracted
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Convert body to Buffer from various sources
|
|
127
|
+
* @param {string|Buffer|ArrayBuffer} body - The body to convert
|
|
128
|
+
* @returns {Buffer} The body as a Buffer
|
|
129
|
+
*/
|
|
130
|
+
export const toBuffer = body => {
|
|
131
|
+
if (!body) {
|
|
132
|
+
return Buffer.alloc(0)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// If it's already a Buffer, return it
|
|
136
|
+
if (Buffer.isBuffer(body)) {
|
|
137
|
+
return body
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// If it's a string, convert to Buffer
|
|
141
|
+
if (typeof body === "string") {
|
|
142
|
+
return Buffer.from(body, "utf-8")
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// If it's an ArrayBuffer or TypedArray
|
|
146
|
+
if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) {
|
|
147
|
+
return Buffer.from(body)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
throw new Error("Unsupported body type")
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Parse ao-ids dictionary
|
|
155
|
+
* @param {string} aoIds - The ao-ids header value
|
|
156
|
+
* @returns {Object} Parsed ID mappings
|
|
157
|
+
*/
|
|
158
|
+
const parseAoIds = aoIds => {
|
|
159
|
+
const result = {}
|
|
160
|
+
|
|
161
|
+
// Match pattern: ID="value"
|
|
162
|
+
const regex = /([A-Za-z0-9_-]{43})="([^"]*)"/g
|
|
163
|
+
let match
|
|
164
|
+
|
|
165
|
+
while ((match = regex.exec(aoIds)) !== null) {
|
|
166
|
+
const [, id, value] = match
|
|
167
|
+
result[id] = value
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return result
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Convert message to JSON with proper type conversions
|
|
175
|
+
* Following the logic from dev_codec_structured, dev_codec_httpsig_conv, and dev_codec_flat
|
|
176
|
+
* @param {Object} msg - The message to convert
|
|
177
|
+
* @returns {Object} JSON representation
|
|
178
|
+
*/
|
|
179
|
+
const toJSON = msg => {
|
|
180
|
+
if (!msg || typeof msg !== "object") {
|
|
181
|
+
return msg
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let result = { ...msg }
|
|
185
|
+
|
|
186
|
+
// Handle ao-ids parsing
|
|
187
|
+
if (result["ao-ids"]) {
|
|
188
|
+
const parsedIds = parseAoIds(result["ao-ids"])
|
|
189
|
+
// Remove the ao-ids header and merge the parsed IDs
|
|
190
|
+
delete result["ao-ids"]
|
|
191
|
+
result = { ...result, ...parsedIds }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// First, handle the multipart body if present
|
|
195
|
+
const contentType = result["content-type"]
|
|
196
|
+
const body = result.body
|
|
197
|
+
|
|
198
|
+
if (body && contentType && contentType.includes("multipart/form-data")) {
|
|
199
|
+
const boundary = getBoundary({ headers: { "content-type": contentType } })
|
|
200
|
+
if (boundary) {
|
|
201
|
+
// Parse multipart body
|
|
202
|
+
const parts = parseMultipartBody(body, boundary)
|
|
203
|
+
|
|
204
|
+
// Remove the raw body since we've parsed it
|
|
205
|
+
delete result.body
|
|
206
|
+
|
|
207
|
+
// Merge parsed parts into result
|
|
208
|
+
for (const [partName, partData] of Object.entries(parts)) {
|
|
209
|
+
// Parse ao-types from part data if present
|
|
210
|
+
const partTypes = {}
|
|
211
|
+
if (partData["ao-types"]) {
|
|
212
|
+
// Updated regex to handle spaces and trim the key
|
|
213
|
+
const matches = [
|
|
214
|
+
...partData["ao-types"].matchAll(/([^=,]+)="([^"]+)"/g),
|
|
215
|
+
]
|
|
216
|
+
for (const [_, key, type] of matches) {
|
|
217
|
+
partTypes[key.trim()] = type
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Apply type conversions to part data
|
|
222
|
+
const convertedPartData = {}
|
|
223
|
+
for (const [key, value] of Object.entries(partData)) {
|
|
224
|
+
if (key === "ao-types" || key === "content-disposition") continue
|
|
225
|
+
|
|
226
|
+
const type = partTypes[key]
|
|
227
|
+
if (type && typeof value === "string") {
|
|
228
|
+
convertedPartData[key] = convertByType(value, type)
|
|
229
|
+
} else {
|
|
230
|
+
convertedPartData[key] = value
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Store the result
|
|
235
|
+
if (Object.keys(convertedPartData).length > 0) {
|
|
236
|
+
// Check if the part name suggests it's an array element (ends with /number)
|
|
237
|
+
const isArrayElement = /\/\d+$/.test(partName)
|
|
238
|
+
|
|
239
|
+
// For top-level parts (no slash in name) with objects, keep the structure
|
|
240
|
+
const isTopLevel = !partName.includes("/")
|
|
241
|
+
|
|
242
|
+
// Check if this is a nested path that will be unflattened
|
|
243
|
+
const isNestedPath = partName.includes("/") && !isArrayElement
|
|
244
|
+
|
|
245
|
+
if (isArrayElement || isNestedPath) {
|
|
246
|
+
// Array elements and nested paths keep their structure
|
|
247
|
+
result[partName] = convertedPartData
|
|
248
|
+
} else if (isTopLevel || Object.keys(convertedPartData).length > 1) {
|
|
249
|
+
// Top-level objects or multi-field objects keep their structure
|
|
250
|
+
result[partName] = convertedPartData
|
|
251
|
+
} else {
|
|
252
|
+
// Only lift single values for simple cases
|
|
253
|
+
result[partName] =
|
|
254
|
+
convertedPartData[Object.keys(convertedPartData)[0]]
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Store type information for nested fields
|
|
259
|
+
if (Object.keys(partTypes).length > 0) {
|
|
260
|
+
for (const [fieldKey, fieldType] of Object.entries(partTypes)) {
|
|
261
|
+
if (fieldKey !== "ao-types" && fieldKey !== "content-disposition") {
|
|
262
|
+
// Add to global typeMap with the full path
|
|
263
|
+
const fullPath = partName + "/" + fieldKey
|
|
264
|
+
// Store in the ao-types for later use
|
|
265
|
+
if (!result["__typeMap"]) result["__typeMap"] = {}
|
|
266
|
+
result["__typeMap"][fullPath] = fieldType
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Parse global ao-types to get type information
|
|
275
|
+
const typeMap = {}
|
|
276
|
+
if (result["ao-types"]) {
|
|
277
|
+
// Updated regex to handle spaces and trim the key
|
|
278
|
+
const matches = [...result["ao-types"].matchAll(/([^=,]+)="([^"]+)"/g)]
|
|
279
|
+
for (const [_, key, type] of matches) {
|
|
280
|
+
typeMap[key.trim()] = type
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Merge in types from multipart parsing
|
|
285
|
+
if (result["__typeMap"]) {
|
|
286
|
+
Object.assign(typeMap, result["__typeMap"])
|
|
287
|
+
delete result["__typeMap"]
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Also collect types from multipart parts for nested fields
|
|
291
|
+
for (const [key, value] of Object.entries(result)) {
|
|
292
|
+
if (
|
|
293
|
+
key.includes("/") &&
|
|
294
|
+
typeof value === "object" &&
|
|
295
|
+
value !== null &&
|
|
296
|
+
value.__partTypes
|
|
297
|
+
) {
|
|
298
|
+
// This is a multipart part with type information
|
|
299
|
+
for (const [fieldKey, fieldType] of Object.entries(value.__partTypes)) {
|
|
300
|
+
// Create the full path for the type
|
|
301
|
+
const fullPath = `${key}/${fieldKey}`
|
|
302
|
+
typeMap[fullPath] = fieldType
|
|
303
|
+
}
|
|
304
|
+
// Remove the __partTypes after processing
|
|
305
|
+
delete value.__partTypes
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Add empty values for fields that are in ao-types but not in result
|
|
310
|
+
for (const [key, type] of Object.entries(typeMap)) {
|
|
311
|
+
if (!(key in result) && type.startsWith("empty-")) {
|
|
312
|
+
result[key] = convertByType("", type)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Apply type conversions and build final result
|
|
317
|
+
const finalResult = {}
|
|
318
|
+
|
|
319
|
+
for (const [key, value] of Object.entries(result)) {
|
|
320
|
+
// Skip internal keys and headers we don't want in the final result
|
|
321
|
+
if (key.startsWith("__")) continue
|
|
322
|
+
|
|
323
|
+
// Skip @ fields EXCEPT @path which we want to include
|
|
324
|
+
if (key.startsWith("@") && key !== "@path") continue
|
|
325
|
+
|
|
326
|
+
if (key === "ao-types") continue
|
|
327
|
+
if (key === "content-type") continue
|
|
328
|
+
if (key === "content-digest") continue
|
|
329
|
+
if (key === "signature") continue
|
|
330
|
+
if (key === "signature-input") continue
|
|
331
|
+
if (key === "body-keys") continue
|
|
332
|
+
|
|
333
|
+
// Get the type for this key
|
|
334
|
+
const type = typeMap[key]
|
|
335
|
+
|
|
336
|
+
// Special handling for objects that should be lists
|
|
337
|
+
if (
|
|
338
|
+
type === "list" &&
|
|
339
|
+
typeof value === "object" &&
|
|
340
|
+
value !== null &&
|
|
341
|
+
!Array.isArray(value)
|
|
342
|
+
) {
|
|
343
|
+
// This is an object that should be converted to a list
|
|
344
|
+
const converted = maybeConvertToArray(value)
|
|
345
|
+
finalResult[key] = converted
|
|
346
|
+
} else if (type && typeof value === "string") {
|
|
347
|
+
// Convert based on type
|
|
348
|
+
finalResult[key] = convertByType(value, type)
|
|
349
|
+
} else if (type && value === undefined) {
|
|
350
|
+
// Handle empty types
|
|
351
|
+
finalResult[key] = convertByType("", type)
|
|
352
|
+
} else if (
|
|
353
|
+
typeof value === "object" &&
|
|
354
|
+
value !== null &&
|
|
355
|
+
!Array.isArray(value)
|
|
356
|
+
) {
|
|
357
|
+
// For objects without a specific type, keep them as objects
|
|
358
|
+
// Only recurse for processing nested values
|
|
359
|
+
const processedObj = {}
|
|
360
|
+
for (const [k, v] of Object.entries(value)) {
|
|
361
|
+
if (Array.isArray(v)) {
|
|
362
|
+
// Keep arrays as arrays
|
|
363
|
+
processedObj[k] = v
|
|
364
|
+
} else if (typeof v === "object" && v !== null) {
|
|
365
|
+
processedObj[k] = toJSON(v)
|
|
366
|
+
} else {
|
|
367
|
+
processedObj[k] = v
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
finalResult[key] = processedObj
|
|
371
|
+
} else {
|
|
372
|
+
// Keep as-is (including arrays)
|
|
373
|
+
finalResult[key] = value
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Handle flattened paths - pass typeMap for context
|
|
378
|
+
return unflattenPaths(finalResult, typeMap)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Convert objects with numeric keys to arrays
|
|
383
|
+
*/
|
|
384
|
+
const maybeConvertToArray = obj => {
|
|
385
|
+
if (Array.isArray(obj)) return obj
|
|
386
|
+
|
|
387
|
+
const keys = Object.keys(obj)
|
|
388
|
+
const numericKeys = keys.filter(k => /^\d+$/.test(k))
|
|
389
|
+
|
|
390
|
+
// If all keys are numeric and sequential starting from 1
|
|
391
|
+
if (numericKeys.length > 0 && numericKeys.length === keys.length) {
|
|
392
|
+
const sortedNumericKeys = numericKeys.map(Number).sort((a, b) => a - b)
|
|
393
|
+
const maxIndex = Math.max(...sortedNumericKeys)
|
|
394
|
+
const arr = []
|
|
395
|
+
|
|
396
|
+
// Fill array based on numeric keys (1-based to 0-based)
|
|
397
|
+
for (let i = 1; i <= maxIndex; i++) {
|
|
398
|
+
if (obj[String(i)] !== undefined) {
|
|
399
|
+
arr[i - 1] = obj[String(i)]
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return arr
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return obj
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Convert value based on its type
|
|
411
|
+
*/
|
|
412
|
+
const convertByType = (value, type) => {
|
|
413
|
+
switch (type) {
|
|
414
|
+
case "integer":
|
|
415
|
+
// Handle structured field integer format
|
|
416
|
+
if (
|
|
417
|
+
typeof value === "string" &&
|
|
418
|
+
value.match(/^"?\(ao-type-integer\)\s+(\d+)"?$/)
|
|
419
|
+
) {
|
|
420
|
+
const match = value.match(/(\d+)/)
|
|
421
|
+
return parseInt(match[1], 10)
|
|
422
|
+
}
|
|
423
|
+
return parseInt(value, 10)
|
|
424
|
+
case "float":
|
|
425
|
+
case "decimal":
|
|
426
|
+
return parseFloat(value)
|
|
427
|
+
case "boolean":
|
|
428
|
+
return value === "true" || value === "?1"
|
|
429
|
+
case "atom":
|
|
430
|
+
// Remove quotes from atom values
|
|
431
|
+
let atomValue = value
|
|
432
|
+
|
|
433
|
+
// Remove any surrounding quotes (single or double)
|
|
434
|
+
if (
|
|
435
|
+
(atomValue.startsWith('"') && atomValue.endsWith('"')) ||
|
|
436
|
+
(atomValue.startsWith("'") && atomValue.endsWith("'"))
|
|
437
|
+
) {
|
|
438
|
+
atomValue = atomValue.slice(1, -1)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Also handle escaped quotes
|
|
442
|
+
atomValue = atomValue.replace(/\\"/g, '"').replace(/\\'/g, "'")
|
|
443
|
+
|
|
444
|
+
// Handle special atom values
|
|
445
|
+
if (atomValue === "true") return true
|
|
446
|
+
if (atomValue === "false") return false
|
|
447
|
+
if (atomValue === "null") return null
|
|
448
|
+
|
|
449
|
+
// For other atoms, return as Symbol
|
|
450
|
+
return Symbol.for(atomValue)
|
|
451
|
+
case "list":
|
|
452
|
+
// Handle case where list is a comma-separated string
|
|
453
|
+
if (typeof value === "string" && !value.startsWith("(")) {
|
|
454
|
+
const items = []
|
|
455
|
+
let current = ""
|
|
456
|
+
let inQuotes = false
|
|
457
|
+
let depth = 0
|
|
458
|
+
|
|
459
|
+
for (let i = 0; i < value.length; i++) {
|
|
460
|
+
const char = value[i]
|
|
461
|
+
const prevChar = value[i - 1]
|
|
462
|
+
|
|
463
|
+
if (char === '"' && prevChar !== "\\") {
|
|
464
|
+
inQuotes = !inQuotes
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (!inQuotes) {
|
|
468
|
+
if (char === "(") {
|
|
469
|
+
depth++
|
|
470
|
+
} else if (char === ")") {
|
|
471
|
+
depth--
|
|
472
|
+
} else if (char === "," && depth === 0) {
|
|
473
|
+
if (current.trim()) {
|
|
474
|
+
// Parse the item to handle type annotations
|
|
475
|
+
items.push(parseStructuredItem(current.trim()))
|
|
476
|
+
}
|
|
477
|
+
current = ""
|
|
478
|
+
continue
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
current += char
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (current.trim()) {
|
|
486
|
+
items.push(parseStructuredItem(current.trim()))
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return items
|
|
490
|
+
}
|
|
491
|
+
return parseStructuredList(value)
|
|
492
|
+
case "map":
|
|
493
|
+
case "dictionary":
|
|
494
|
+
return parseStructuredDict(value)
|
|
495
|
+
case "empty-binary":
|
|
496
|
+
return ""
|
|
497
|
+
case "empty-list":
|
|
498
|
+
return []
|
|
499
|
+
case "empty-message":
|
|
500
|
+
return {}
|
|
501
|
+
default:
|
|
502
|
+
return value
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Parse structured field list
|
|
508
|
+
*/
|
|
509
|
+
const parseStructuredList = value => {
|
|
510
|
+
if (!value || value === "()") return []
|
|
511
|
+
|
|
512
|
+
// Remove outer quotes if present
|
|
513
|
+
let content = value.trim()
|
|
514
|
+
if (content.startsWith('"') && content.endsWith('"')) {
|
|
515
|
+
content = content.slice(1, -1)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const items = []
|
|
519
|
+
let current = ""
|
|
520
|
+
let inQuotes = false
|
|
521
|
+
let depth = 0
|
|
522
|
+
|
|
523
|
+
for (let i = 0; i < content.length; i++) {
|
|
524
|
+
const char = content[i]
|
|
525
|
+
const prevChar = content[i - 1]
|
|
526
|
+
|
|
527
|
+
if (char === '"' && prevChar !== "\\") {
|
|
528
|
+
inQuotes = !inQuotes
|
|
529
|
+
current += char
|
|
530
|
+
} else if (!inQuotes) {
|
|
531
|
+
if (char === "(") {
|
|
532
|
+
depth++
|
|
533
|
+
current += char
|
|
534
|
+
} else if (char === ")") {
|
|
535
|
+
depth--
|
|
536
|
+
current += char
|
|
537
|
+
} else if (char === "," && depth === 0) {
|
|
538
|
+
if (current.trim()) {
|
|
539
|
+
items.push(parseStructuredItem(current.trim()))
|
|
540
|
+
}
|
|
541
|
+
current = ""
|
|
542
|
+
} else {
|
|
543
|
+
current += char
|
|
544
|
+
}
|
|
545
|
+
} else {
|
|
546
|
+
current += char
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (current.trim()) {
|
|
551
|
+
items.push(parseStructuredItem(current.trim()))
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return items
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Parse structured field item
|
|
559
|
+
*/
|
|
560
|
+
const parseStructuredItem = item => {
|
|
561
|
+
// Quoted string - handle first to properly process inner content
|
|
562
|
+
if (item.startsWith('"') && item.endsWith('"')) {
|
|
563
|
+
const inner = item.slice(1, -1).replace(/\\"/g, '"')
|
|
564
|
+
|
|
565
|
+
// Check if the inner content is ao-type encoded
|
|
566
|
+
const innerAoTypeMatch = inner.match(/^\(ao-type-(\w+)\)\s+(.+)$/)
|
|
567
|
+
if (innerAoTypeMatch) {
|
|
568
|
+
const [, type, value] = innerAoTypeMatch
|
|
569
|
+
// The value here has already had escaped quotes converted to real quotes
|
|
570
|
+
// If it's wrapped in quotes, remove them
|
|
571
|
+
let cleanValue = value
|
|
572
|
+
if (
|
|
573
|
+
(cleanValue.startsWith('"') && cleanValue.endsWith('"')) ||
|
|
574
|
+
(cleanValue.startsWith("'") && cleanValue.endsWith("'"))
|
|
575
|
+
) {
|
|
576
|
+
cleanValue = cleanValue.slice(1, -1)
|
|
577
|
+
}
|
|
578
|
+
return convertByType(cleanValue, type)
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
return inner
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Handle ao-type encoded items without outer quotes
|
|
585
|
+
const aoTypeMatch = item.match(/^\(ao-type-(\w+)\)\s+(.+)$/)
|
|
586
|
+
if (aoTypeMatch) {
|
|
587
|
+
const [, type, value] = aoTypeMatch
|
|
588
|
+
let cleanValue = value
|
|
589
|
+
// Handle escaped quotes
|
|
590
|
+
cleanValue = cleanValue.replace(/\\"/g, '"')
|
|
591
|
+
// If wrapped in quotes, remove them
|
|
592
|
+
if (
|
|
593
|
+
(cleanValue.startsWith('"') && cleanValue.endsWith('"')) ||
|
|
594
|
+
(cleanValue.startsWith("'") && cleanValue.endsWith("'"))
|
|
595
|
+
) {
|
|
596
|
+
cleanValue = cleanValue.slice(1, -1)
|
|
597
|
+
}
|
|
598
|
+
return convertByType(cleanValue, type)
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Boolean
|
|
602
|
+
if (item === "?1") return true
|
|
603
|
+
if (item === "?0") return false
|
|
604
|
+
|
|
605
|
+
// Number
|
|
606
|
+
if (/^-?\d+$/.test(item)) {
|
|
607
|
+
return parseInt(item, 10)
|
|
608
|
+
}
|
|
609
|
+
if (/^-?\d+\.\d+$/.test(item)) {
|
|
610
|
+
return parseFloat(item)
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// Nested list
|
|
614
|
+
if (item.startsWith("(") && item.endsWith(")")) {
|
|
615
|
+
return parseStructuredList(item)
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
return item
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Parse structured field dictionary
|
|
623
|
+
*/
|
|
624
|
+
const parseStructuredDict = value => {
|
|
625
|
+
const decoded = decodeSigInput(value)
|
|
626
|
+
const result = {}
|
|
627
|
+
|
|
628
|
+
for (const [key, info] of Object.entries(decoded)) {
|
|
629
|
+
if (info && info.components && info.components.length > 0) {
|
|
630
|
+
result[key] = parseStructuredItem(info.components[0])
|
|
631
|
+
} else {
|
|
632
|
+
result[key] = true
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return result
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Parse body-keys list
|
|
641
|
+
*/
|
|
642
|
+
const parseBodyKeysList = value => {
|
|
643
|
+
const matches = [...value.matchAll(/"([^"]+)"/g)]
|
|
644
|
+
return matches.map(m => m[1])
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Parse multipart body
|
|
649
|
+
*/
|
|
650
|
+
const parseMultipartBody = (body, boundary) => {
|
|
651
|
+
const result = {}
|
|
652
|
+
|
|
653
|
+
// Split by boundary lines
|
|
654
|
+
const parts = body.split(`--${boundary}`)
|
|
655
|
+
|
|
656
|
+
for (let i = 0; i < parts.length; i++) {
|
|
657
|
+
const part = parts[i]
|
|
658
|
+
|
|
659
|
+
// Skip empty parts and the terminating part
|
|
660
|
+
if (!part || part === "--" || part === "--\r\n" || part.trim() === "")
|
|
661
|
+
continue
|
|
662
|
+
|
|
663
|
+
// Remove leading \r\n if present
|
|
664
|
+
let content = part
|
|
665
|
+
if (content.startsWith("\r\n")) {
|
|
666
|
+
content = content.substring(2)
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Remove trailing \r\n or -- if present
|
|
670
|
+
if (content.endsWith("\r\n")) {
|
|
671
|
+
content = content.substring(0, content.length - 2)
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (!content) continue
|
|
675
|
+
|
|
676
|
+
// Parse all lines
|
|
677
|
+
const lines = content.split("\r\n")
|
|
678
|
+
const partData = {}
|
|
679
|
+
let partName = null
|
|
680
|
+
|
|
681
|
+
for (const line of lines) {
|
|
682
|
+
if (!line) continue
|
|
683
|
+
|
|
684
|
+
const colonIndex = line.indexOf(": ")
|
|
685
|
+
if (colonIndex > -1) {
|
|
686
|
+
const name = line.substring(0, colonIndex)
|
|
687
|
+
const value = line.substring(colonIndex + 2)
|
|
688
|
+
|
|
689
|
+
// Check if this is content-disposition to extract part name
|
|
690
|
+
if (name.toLowerCase() === "content-disposition") {
|
|
691
|
+
const nameMatch = value.match(/name="([^"]+)"/)
|
|
692
|
+
if (nameMatch) {
|
|
693
|
+
partName = nameMatch[1]
|
|
694
|
+
}
|
|
695
|
+
} else {
|
|
696
|
+
// Store all other headers/fields
|
|
697
|
+
partData[name] = value
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// Store the part data under its name
|
|
703
|
+
if (partName && Object.keys(partData).length > 0) {
|
|
704
|
+
result[partName] = partData
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
return result
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Unflatten paths with '/'
|
|
713
|
+
*/
|
|
714
|
+
const unflattenPaths = (obj, typeMap = {}) => {
|
|
715
|
+
const result = {}
|
|
716
|
+
|
|
717
|
+
// Check if there are any paths to unflatten
|
|
718
|
+
const hasPathsToUnflatten = Object.keys(obj).some(key => key.includes("/"))
|
|
719
|
+
|
|
720
|
+
// If no paths to unflatten, return the object as-is
|
|
721
|
+
if (!hasPathsToUnflatten) {
|
|
722
|
+
return obj
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// First pass: collect all keys and sort them to process parents before children
|
|
726
|
+
const sortedKeys = Object.keys(obj).sort()
|
|
727
|
+
|
|
728
|
+
for (const key of sortedKeys) {
|
|
729
|
+
const value = obj[key]
|
|
730
|
+
|
|
731
|
+
if (key.includes("/")) {
|
|
732
|
+
const parts = key.split("/")
|
|
733
|
+
let current = result
|
|
734
|
+
let i = 0
|
|
735
|
+
|
|
736
|
+
while (i < parts.length - 1) {
|
|
737
|
+
let part = parts[i]
|
|
738
|
+
|
|
739
|
+
// Check if we have consecutive empty parts (multiple slashes)
|
|
740
|
+
if (part === "" && i + 1 < parts.length && parts[i + 1] === "") {
|
|
741
|
+
// Skip empty parts until we find a non-empty one or reach the end
|
|
742
|
+
let j = i
|
|
743
|
+
while (j < parts.length && parts[j] === "") {
|
|
744
|
+
j++
|
|
745
|
+
}
|
|
746
|
+
// Use "/" as the key for multiple slashes
|
|
747
|
+
part = "/"
|
|
748
|
+
i = j - 1 // Will be incremented at the end of loop
|
|
749
|
+
} else if (part === "") {
|
|
750
|
+
// Single empty part at the beginning or middle, skip it
|
|
751
|
+
i++
|
|
752
|
+
continue
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
if (!current[part]) {
|
|
756
|
+
current[part] = {}
|
|
757
|
+
}
|
|
758
|
+
current = current[part]
|
|
759
|
+
i++
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// Handle the final part
|
|
763
|
+
const finalPart = parts[parts.length - 1]
|
|
764
|
+
current[finalPart] = value
|
|
765
|
+
} else {
|
|
766
|
+
result[key] = value
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// Second pass: convert objects with numeric keys to arrays only if they have type="list"
|
|
771
|
+
const convertToArraysRecursive = (obj, parentKey = "") => {
|
|
772
|
+
if (Array.isArray(obj)) {
|
|
773
|
+
return obj.map((item, index) =>
|
|
774
|
+
convertToArraysRecursive(item, `${parentKey}/${index + 1}`)
|
|
775
|
+
)
|
|
776
|
+
} else if (obj && typeof obj === "object") {
|
|
777
|
+
// Check if this object has type="list" in typeMap
|
|
778
|
+
const hasListType = typeMap[parentKey] === "list"
|
|
779
|
+
|
|
780
|
+
// Only convert to array if it has numeric keys AND type="list"
|
|
781
|
+
if (hasListType) {
|
|
782
|
+
const converted = maybeConvertToArray(obj)
|
|
783
|
+
if (Array.isArray(converted)) {
|
|
784
|
+
return converted.map((item, index) =>
|
|
785
|
+
convertToArraysRecursive(item, `${parentKey}/${index + 1}`)
|
|
786
|
+
)
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// Otherwise, keep as object and recurse
|
|
791
|
+
const result = {}
|
|
792
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
793
|
+
const childKey = parentKey ? `${parentKey}/${key}` : key
|
|
794
|
+
result[key] = convertToArraysRecursive(value, childKey)
|
|
795
|
+
}
|
|
796
|
+
return result
|
|
797
|
+
}
|
|
798
|
+
return obj
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
return convertToArraysRecursive(result)
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Helper to check if a key pattern suggests an array structure
|
|
806
|
+
*/
|
|
807
|
+
const isArrayKey = (obj, currentKey, partIndex) => {
|
|
808
|
+
const parts = currentKey.split("/")
|
|
809
|
+
const prefix = parts.slice(0, partIndex + 1).join("/")
|
|
810
|
+
|
|
811
|
+
// Check if there are other keys with the same prefix but numeric suffixes
|
|
812
|
+
for (const key of Object.keys(obj)) {
|
|
813
|
+
if (key.startsWith(prefix + "/") && key !== currentKey) {
|
|
814
|
+
const otherParts = key.split("/")
|
|
815
|
+
if (
|
|
816
|
+
otherParts.length > partIndex + 1 &&
|
|
817
|
+
/^\d+$/.test(otherParts[partIndex + 1])
|
|
818
|
+
) {
|
|
819
|
+
return true
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
return false
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* Check if a string contains binary data (non-printable characters)
|
|
829
|
+
* @param {string} str - The string to check
|
|
830
|
+
* @returns {boolean} True if binary data detected
|
|
831
|
+
*/
|
|
832
|
+
const isBinaryString = str => {
|
|
833
|
+
if (!str || typeof str !== "string") return false
|
|
834
|
+
|
|
835
|
+
// Check for non-printable characters (excluding common whitespace)
|
|
836
|
+
for (let i = 0; i < str.length; i++) {
|
|
837
|
+
const code = str.charCodeAt(i)
|
|
838
|
+
// Allow tab (9), newline (10), carriage return (13), and printable ASCII (32-126)
|
|
839
|
+
if (code < 9 || (code > 13 && code < 32) || code > 126) {
|
|
840
|
+
return true
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
return false
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Convert binary string to Buffer
|
|
848
|
+
* @param {string} str - Binary string to convert
|
|
849
|
+
* @returns {Buffer} Buffer representation of the string
|
|
850
|
+
*/
|
|
851
|
+
const stringToBuffer = str => {
|
|
852
|
+
const buffer = Buffer.alloc(str.length)
|
|
853
|
+
for (let i = 0; i < str.length; i++) {
|
|
854
|
+
buffer[i] = str.charCodeAt(i)
|
|
855
|
+
}
|
|
856
|
+
return buffer
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
export const from = http => {
|
|
860
|
+
const input =
|
|
861
|
+
http.headers["signature-input"] || http.headers["Signature-Input"]
|
|
862
|
+
if (!input) {
|
|
863
|
+
return null
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// Decode signature inputs
|
|
867
|
+
const inputs = decodeSigInput(input)
|
|
868
|
+
// Process the first signature (following the original logic)
|
|
869
|
+
for (const k in inputs) {
|
|
870
|
+
const sigData = inputs[k]
|
|
871
|
+
// Extract only the signed components
|
|
872
|
+
const extractedComponents = extract(http, sigData.components)
|
|
873
|
+
let ret = { hashpath: sigData?.params?.tag ?? null }
|
|
874
|
+
try {
|
|
875
|
+
ret.signer = toAddr(sigData.params.keyid)
|
|
876
|
+
} catch (e) {}
|
|
877
|
+
|
|
878
|
+
// Check if @path was in the signed components
|
|
879
|
+
const hasPathComponent = sigData.components.some(
|
|
880
|
+
c => c.replace(/"/g, "") === "@path"
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
// If @path is signed, add the path header to extracted components
|
|
884
|
+
if (hasPathComponent) {
|
|
885
|
+
extractedComponents["path"] = http.headers.path
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// Check if ao-result header is present
|
|
889
|
+
const aoResult = http.headers["ao-result"] || http.headers["Ao-Result"]
|
|
890
|
+
|
|
891
|
+
// Handle ao-result pointing to body
|
|
892
|
+
if (aoResult === "body") {
|
|
893
|
+
// Handle empty body case
|
|
894
|
+
if (!extractedComponents.body) {
|
|
895
|
+
return { out: "", ...ret } // Return empty string for empty body
|
|
896
|
+
}
|
|
897
|
+
// Check if body is binary data
|
|
898
|
+
if (isBinaryString(extractedComponents.body)) {
|
|
899
|
+
return { out: stringToBuffer(extractedComponents.body), ...ret }
|
|
900
|
+
}
|
|
901
|
+
// Return body as-is if it's not binary
|
|
902
|
+
return { out: extractedComponents.body, ...ret }
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// Convert the extracted components to JSON format
|
|
906
|
+
const result = toJSON(extractedComponents)
|
|
907
|
+
|
|
908
|
+
// Handle ao-result if present and pointing to other fields
|
|
909
|
+
if (aoResult && aoResult !== "body") {
|
|
910
|
+
// Return the value of the key specified by ao-result
|
|
911
|
+
// If the key doesn't exist, return undefined (or could return null/empty string)
|
|
912
|
+
return {
|
|
913
|
+
out: result[aoResult] !== undefined ? result[aoResult] : "",
|
|
914
|
+
...ret,
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
return { out: result, ...ret }
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
return { out: null, hashpath: null }
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Extract all keys and signature information from HTTP signature message
|
|
926
|
+
* @param {Object} http - HTTP message object with headers and body
|
|
927
|
+
* @returns {Object} Object containing all extracted signature data
|
|
928
|
+
*/
|
|
929
|
+
export const extractKeys = http => {
|
|
930
|
+
const result = {
|
|
931
|
+
signatures: {},
|
|
932
|
+
keys: {},
|
|
933
|
+
boundary: null,
|
|
934
|
+
requiresBody: false,
|
|
935
|
+
body: http.body ? toBuffer(http.body) : null,
|
|
936
|
+
bodyText: typeof http.body === "string" ? http.body : null,
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// Get multipart boundary if present
|
|
940
|
+
result.boundary = getBoundary(http)
|
|
941
|
+
|
|
942
|
+
// Get signature header
|
|
943
|
+
const signatureHeader = http.headers.signature || http.headers.Signature
|
|
944
|
+
if (!signatureHeader) {
|
|
945
|
+
return result
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// Get signature-input header
|
|
949
|
+
const signatureInput =
|
|
950
|
+
http.headers["signature-input"] || http.headers["Signature-Input"]
|
|
951
|
+
if (!signatureInput) {
|
|
952
|
+
return result
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// Decode all signature inputs
|
|
956
|
+
const inputs = decodeSigInput(signatureInput)
|
|
957
|
+
|
|
958
|
+
// Parse signature header to extract actual signatures
|
|
959
|
+
// Format: sig1=:base64signature:, sig2=:base64signature:
|
|
960
|
+
const signatures = {}
|
|
961
|
+
const sigPattern = /([a-zA-Z0-9-]+)=:([^:]+):/g
|
|
962
|
+
let match
|
|
963
|
+
while ((match = sigPattern.exec(signatureHeader)) !== null) {
|
|
964
|
+
signatures[match[1]] = match[2]
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// Process each signature
|
|
968
|
+
for (const [sigName, sigData] of Object.entries(inputs)) {
|
|
969
|
+
const extractedValues = extract(http, sigData.components)
|
|
970
|
+
|
|
971
|
+
// Check if body is required
|
|
972
|
+
if (extractedValues.__bodyRequired__) {
|
|
973
|
+
result.requiresBody = true
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const signatureInfo = {
|
|
977
|
+
name: sigName,
|
|
978
|
+
signature: signatures[sigName] || null,
|
|
979
|
+
components: sigData.components,
|
|
980
|
+
params: sigData.params,
|
|
981
|
+
extractedValues: extractedValues,
|
|
982
|
+
hasContentDigest: sigData.components.some(
|
|
983
|
+
c => c.replace(/"/g, "").toLowerCase() === "content-digest"
|
|
984
|
+
),
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// If has content-digest, verify it
|
|
988
|
+
if (signatureInfo.hasContentDigest && result.body) {
|
|
989
|
+
const contentDigest =
|
|
990
|
+
http.headers["content-digest"] || http.headers["Content-Digest"]
|
|
991
|
+
if (contentDigest) {
|
|
992
|
+
signatureInfo.contentDigestVerification = verifyContentDigest(
|
|
993
|
+
contentDigest,
|
|
994
|
+
result.body
|
|
995
|
+
)
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// Extract key information from params
|
|
1000
|
+
if (sigData.params.keyid) {
|
|
1001
|
+
try {
|
|
1002
|
+
const keyBuffer = base64url.toBuffer(sigData.params.keyid)
|
|
1003
|
+
result.keys[sigName] = {
|
|
1004
|
+
keyid: sigData.params.keyid,
|
|
1005
|
+
keyBuffer: keyBuffer,
|
|
1006
|
+
algorithm: sigData.params.alg || "unknown",
|
|
1007
|
+
}
|
|
1008
|
+
} catch (e) {
|
|
1009
|
+
// If keyid is not base64url encoded, store as-is
|
|
1010
|
+
result.keys[sigName] = {
|
|
1011
|
+
keyid: sigData.params.keyid,
|
|
1012
|
+
algorithm: sigData.params.alg || "unknown",
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
result.signatures[sigName] = signatureInfo
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
return result
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* Verify if a message has valid HTTP signature structure
|
|
1025
|
+
* @param {Object} http - HTTP message object
|
|
1026
|
+
* @returns {boolean} True if message has valid signature structure
|
|
1027
|
+
*/
|
|
1028
|
+
export const hasValidSignature = http => {
|
|
1029
|
+
const hasSignature =
|
|
1030
|
+
(http.headers.signature || http.headers.Signature) !== undefined
|
|
1031
|
+
const hasSignatureInput =
|
|
1032
|
+
(http.headers["signature-input"] || http.headers["Signature-Input"]) !==
|
|
1033
|
+
undefined
|
|
1034
|
+
return hasSignature && hasSignatureInput
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* Verify content-digest header against body
|
|
1039
|
+
* @param {string} contentDigest - Content-Digest header value
|
|
1040
|
+
* @param {string|Buffer} body - Request/response body
|
|
1041
|
+
* @returns {Object} Verification result with digest info
|
|
1042
|
+
*/
|
|
1043
|
+
export const verifyContentDigest = (contentDigest, body) => {
|
|
1044
|
+
// Parse content-digest header format: algorithm=:base64digest:
|
|
1045
|
+
const match = contentDigest.match(/([^=]+)=:([^:]+):/)
|
|
1046
|
+
if (!match) {
|
|
1047
|
+
return { valid: false, error: "Invalid content-digest format" }
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
const [, algorithm, expectedDigest] = match
|
|
1051
|
+
|
|
1052
|
+
try {
|
|
1053
|
+
// Convert body to Buffer if needed
|
|
1054
|
+
const bodyBuffer =
|
|
1055
|
+
typeof body === "string" ? Buffer.from(body, "utf-8") : body
|
|
1056
|
+
|
|
1057
|
+
// Calculate digest based on algorithm
|
|
1058
|
+
let actualDigest
|
|
1059
|
+
const crypto = require("crypto")
|
|
1060
|
+
|
|
1061
|
+
if (algorithm === "sha-256") {
|
|
1062
|
+
const hash = crypto.createHash("sha256")
|
|
1063
|
+
hash.update(bodyBuffer)
|
|
1064
|
+
actualDigest = hash.digest("base64")
|
|
1065
|
+
} else if (algorithm === "sha-512") {
|
|
1066
|
+
const hash = crypto.createHash("sha512")
|
|
1067
|
+
hash.update(bodyBuffer)
|
|
1068
|
+
actualDigest = hash.digest("base64")
|
|
1069
|
+
} else {
|
|
1070
|
+
return { valid: false, error: `Unsupported algorithm: ${algorithm}` }
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
return {
|
|
1074
|
+
valid: actualDigest === expectedDigest,
|
|
1075
|
+
algorithm,
|
|
1076
|
+
expectedDigest,
|
|
1077
|
+
actualDigest,
|
|
1078
|
+
matches: actualDigest === expectedDigest,
|
|
1079
|
+
}
|
|
1080
|
+
} catch (error) {
|
|
1081
|
+
return { valid: false, error: error.message }
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* Get all signature names from a message
|
|
1087
|
+
* @param {Object} http - HTTP message object
|
|
1088
|
+
* @returns {Array} Array of signature names
|
|
1089
|
+
*/
|
|
1090
|
+
export const getSignatureNames = http => {
|
|
1091
|
+
const signatureInput =
|
|
1092
|
+
http.headers["signature-input"] || http.headers["Signature-Input"]
|
|
1093
|
+
if (!signatureInput) return []
|
|
1094
|
+
|
|
1095
|
+
const inputs = decodeSigInput(signatureInput)
|
|
1096
|
+
return Object.keys(inputs)
|
|
1097
|
+
}
|