hbsig 0.3.2 → 0.3.3

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