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.
Files changed (44) hide show
  1. package/cjs/bin_to_str.js +44 -0
  2. package/cjs/collect-body-keys.js +470 -0
  3. package/cjs/encode-array-item.js +110 -0
  4. package/cjs/encode-utils.js +236 -0
  5. package/cjs/encode.js +1318 -0
  6. package/cjs/erl_json.js +317 -0
  7. package/cjs/erl_str.js +1037 -0
  8. package/cjs/flat.js +222 -0
  9. package/cjs/http-message-signatures/httpbis.js +489 -0
  10. package/cjs/http-message-signatures/index.js +25 -0
  11. package/cjs/http-message-signatures/structured-header.js +129 -0
  12. package/cjs/httpsig.js +716 -0
  13. package/cjs/httpsig2.js +1160 -0
  14. package/cjs/id.js +470 -0
  15. package/cjs/index.js +63 -0
  16. package/cjs/send.js +194 -0
  17. package/cjs/signer-utils.js +617 -0
  18. package/cjs/signer.js +606 -0
  19. package/cjs/structured.js +296 -0
  20. package/cjs/test.js +27 -0
  21. package/cjs/utils.js +42 -0
  22. package/esm/bin_to_str.js +46 -0
  23. package/esm/collect-body-keys.js +436 -0
  24. package/esm/encode-array-item.js +112 -0
  25. package/esm/encode-utils.js +185 -0
  26. package/esm/encode.js +1219 -0
  27. package/esm/erl_json.js +289 -0
  28. package/esm/erl_str.js +1139 -0
  29. package/esm/flat.js +196 -0
  30. package/esm/http-message-signatures/httpbis.js +438 -0
  31. package/esm/http-message-signatures/index.js +4 -0
  32. package/esm/http-message-signatures/structured-header.js +105 -0
  33. package/esm/httpsig.js +658 -0
  34. package/esm/httpsig2.js +1097 -0
  35. package/esm/id.js +459 -0
  36. package/esm/index.js +4 -0
  37. package/esm/package.json +3 -0
  38. package/esm/send.js +124 -0
  39. package/esm/signer-utils.js +494 -0
  40. package/esm/signer.js +452 -0
  41. package/esm/structured.js +269 -0
  42. package/esm/test.js +6 -0
  43. package/esm/utils.js +28 -0
  44. package/package.json +28 -0
@@ -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
+ }