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/httpsig.js
ADDED
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
// httpsig.js - JavaScript implementation of HTTP Signature codec
|
|
2
|
+
|
|
3
|
+
import crypto from "crypto"
|
|
4
|
+
import { flat_from, flat_to } from "./flat.js"
|
|
5
|
+
|
|
6
|
+
const CRLF = "\r\n"
|
|
7
|
+
const DOUBLE_CRLF = CRLF + CRLF
|
|
8
|
+
const MAX_HEADER_LENGTH = 4096
|
|
9
|
+
|
|
10
|
+
// Helper to normalize keys (lowercase only, no underscore conversion)
|
|
11
|
+
function normalizeKey(key) {
|
|
12
|
+
if (typeof key === "string") {
|
|
13
|
+
return key.toLowerCase()
|
|
14
|
+
}
|
|
15
|
+
return String(key).toLowerCase()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Helper to check if a value is an ID (43 character base64url string)
|
|
19
|
+
function isId(value) {
|
|
20
|
+
return (
|
|
21
|
+
typeof value === "string" &&
|
|
22
|
+
value.length === 43 &&
|
|
23
|
+
/^[A-Za-z0-9_-]+$/.test(value)
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Helper to encode structured field dictionary
|
|
28
|
+
function encodeSfDict(dict) {
|
|
29
|
+
const items = []
|
|
30
|
+
for (const [key, value] of Object.entries(dict)) {
|
|
31
|
+
if (typeof value === "string") {
|
|
32
|
+
items.push(`${key}="${value}"`)
|
|
33
|
+
} else {
|
|
34
|
+
items.push(`${key}=${value}`)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return items.join(", ")
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Helper to parse structured field dictionary
|
|
41
|
+
function parseSfDict(str) {
|
|
42
|
+
const dict = {}
|
|
43
|
+
if (!str) return dict
|
|
44
|
+
|
|
45
|
+
const parts = str.split(/,\s*/)
|
|
46
|
+
for (const part of parts) {
|
|
47
|
+
const match = part.match(/^([^=]+)=(.+)$/)
|
|
48
|
+
if (match) {
|
|
49
|
+
const key = match[1].trim()
|
|
50
|
+
let value = match[2].trim()
|
|
51
|
+
if (value.startsWith('"') && value.endsWith('"')) {
|
|
52
|
+
value = value.slice(1, -1)
|
|
53
|
+
}
|
|
54
|
+
dict[key] = value
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return dict
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Helper to generate boundary from parts
|
|
61
|
+
function boundaryFromParts(parts) {
|
|
62
|
+
const bodyBin = parts.map(p => p.body).join(CRLF)
|
|
63
|
+
const hash = crypto.createHash("sha256")
|
|
64
|
+
hash.update(bodyBin)
|
|
65
|
+
return hash.digest("base64url")
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Helper to determine inline key
|
|
69
|
+
function inlineKey(msg) {
|
|
70
|
+
const inlineBodyKey = msg["inline-body-key"]
|
|
71
|
+
if (inlineBodyKey) {
|
|
72
|
+
return [{}, inlineBodyKey]
|
|
73
|
+
}
|
|
74
|
+
if ("body" in msg) {
|
|
75
|
+
return [{}, "body"]
|
|
76
|
+
}
|
|
77
|
+
if ("data" in msg) {
|
|
78
|
+
return [{ "inline-body-key": "data" }, "data"]
|
|
79
|
+
}
|
|
80
|
+
return [{}, "body"]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Group IDs into ao-ids field
|
|
84
|
+
function groupIds(map) {
|
|
85
|
+
const idDict = {}
|
|
86
|
+
const stripped = {}
|
|
87
|
+
|
|
88
|
+
for (const [key, value] of Object.entries(map)) {
|
|
89
|
+
if (isId(key) && typeof value === "string") {
|
|
90
|
+
// Store with lowercase key as in Erlang
|
|
91
|
+
idDict[key.toLowerCase()] = value
|
|
92
|
+
} else {
|
|
93
|
+
stripped[key] = value
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (Object.keys(idDict).length > 0) {
|
|
98
|
+
const items = []
|
|
99
|
+
for (const [k, v] of Object.entries(idDict)) {
|
|
100
|
+
items.push(`${k}="${v}"`)
|
|
101
|
+
}
|
|
102
|
+
stripped["ao-ids"] = items.join(", ")
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Return IDs as lowercase in the result since they will be normalized
|
|
106
|
+
// when processed as headers
|
|
107
|
+
for (const [k, v] of Object.entries(idDict)) {
|
|
108
|
+
stripped[k] = v
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return stripped
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Ungroup IDs from ao-ids field
|
|
115
|
+
function ungroupIds(msg) {
|
|
116
|
+
if (!msg["ao-ids"]) return msg
|
|
117
|
+
|
|
118
|
+
const result = { ...msg }
|
|
119
|
+
delete result["ao-ids"]
|
|
120
|
+
|
|
121
|
+
const dict = parseSfDict(msg["ao-ids"])
|
|
122
|
+
for (const [key, value] of Object.entries(dict)) {
|
|
123
|
+
// ao-ids keys are lowercase, use them as-is
|
|
124
|
+
result[key] = value
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return result
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Group maps for body encoding - following Erlang logic exactly
|
|
131
|
+
function groupMaps(map, parent = "", top = {}) {
|
|
132
|
+
if (
|
|
133
|
+
typeof map !== "object" ||
|
|
134
|
+
map === null ||
|
|
135
|
+
Array.isArray(map) ||
|
|
136
|
+
Buffer.isBuffer(map)
|
|
137
|
+
) {
|
|
138
|
+
return top
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const flattened = {}
|
|
142
|
+
let newTop = { ...top }
|
|
143
|
+
|
|
144
|
+
// Process entries in sorted order for consistency
|
|
145
|
+
const entries = Object.entries(map).sort(([a], [b]) => a.localeCompare(b))
|
|
146
|
+
|
|
147
|
+
for (const [key, value] of entries) {
|
|
148
|
+
// Normalize keys to lowercase
|
|
149
|
+
const normKey = normalizeKey(key)
|
|
150
|
+
const flatK = parent ? `${parent}/${normKey}` : normKey
|
|
151
|
+
|
|
152
|
+
if (
|
|
153
|
+
typeof value === "object" &&
|
|
154
|
+
value !== null &&
|
|
155
|
+
!Array.isArray(value) &&
|
|
156
|
+
!Buffer.isBuffer(value)
|
|
157
|
+
) {
|
|
158
|
+
// Recursively process nested objects
|
|
159
|
+
newTop = groupMaps(value, flatK, newTop)
|
|
160
|
+
} else if (typeof value === "string" && value.length > MAX_HEADER_LENGTH) {
|
|
161
|
+
// Value too large for header, lift to top level
|
|
162
|
+
newTop[flatK] = value
|
|
163
|
+
} else if (Buffer.isBuffer(value) && value.length > MAX_HEADER_LENGTH) {
|
|
164
|
+
// Large buffers also get lifted to top level
|
|
165
|
+
newTop[flatK] = value
|
|
166
|
+
} else {
|
|
167
|
+
// Keep in current flattened map
|
|
168
|
+
flattened[normKey] = value
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (Object.keys(flattened).length === 0) {
|
|
173
|
+
return newTop
|
|
174
|
+
} else if (parent) {
|
|
175
|
+
// Add flattened map under parent key
|
|
176
|
+
return { ...newTop, [parent]: flattened }
|
|
177
|
+
} else {
|
|
178
|
+
// Merge flattened with top level
|
|
179
|
+
return { ...newTop, ...flattened }
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Encode multipart body part
|
|
184
|
+
function encodeBodyPart(partName, bodyPart, inlineKey) {
|
|
185
|
+
const disposition =
|
|
186
|
+
partName === inlineKey ? "inline" : `form-data;name="${partName}"`
|
|
187
|
+
const isInline = partName === inlineKey
|
|
188
|
+
|
|
189
|
+
if (
|
|
190
|
+
typeof bodyPart === "object" &&
|
|
191
|
+
bodyPart !== null &&
|
|
192
|
+
!Array.isArray(bodyPart) &&
|
|
193
|
+
!Buffer.isBuffer(bodyPart)
|
|
194
|
+
) {
|
|
195
|
+
// Check if this part has ao-types
|
|
196
|
+
const hasAoTypes = "ao-types" in bodyPart
|
|
197
|
+
|
|
198
|
+
if (hasAoTypes) {
|
|
199
|
+
// For parts WITH ao-types: sort all entries alphabetically
|
|
200
|
+
const allEntries = []
|
|
201
|
+
|
|
202
|
+
// Collect all entries except body
|
|
203
|
+
for (const [key, value] of Object.entries(bodyPart)) {
|
|
204
|
+
if (key === "body") continue
|
|
205
|
+
|
|
206
|
+
if (key === "ao-types") {
|
|
207
|
+
allEntries.push({ key: "ao-types", line: `ao-types: ${value}` })
|
|
208
|
+
} else {
|
|
209
|
+
// Handle Buffer values properly
|
|
210
|
+
let valueStr = value
|
|
211
|
+
if (Buffer.isBuffer(value)) {
|
|
212
|
+
// Use binary/latin1 encoding to preserve all byte values 0-255
|
|
213
|
+
valueStr = value.toString("binary")
|
|
214
|
+
}
|
|
215
|
+
allEntries.push({ key: key, line: `${key}: ${valueStr}` })
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Add content-disposition
|
|
220
|
+
allEntries.push({
|
|
221
|
+
key: "content-disposition",
|
|
222
|
+
line: `content-disposition: ${disposition}`,
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
// Sort alphabetically by key
|
|
226
|
+
allEntries.sort((a, b) => a.key.localeCompare(b.key))
|
|
227
|
+
|
|
228
|
+
// Build the lines
|
|
229
|
+
const lines = allEntries.map(entry => entry.line)
|
|
230
|
+
|
|
231
|
+
// Body handling
|
|
232
|
+
const body = bodyPart.body || ""
|
|
233
|
+
if (body) {
|
|
234
|
+
lines.push("") // Always add empty line before body
|
|
235
|
+
lines.push(body)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return lines.join(CRLF)
|
|
239
|
+
} else {
|
|
240
|
+
// For parts WITHOUT ao-types
|
|
241
|
+
const allEntries = []
|
|
242
|
+
|
|
243
|
+
for (const [key, value] of Object.entries(bodyPart)) {
|
|
244
|
+
if (key === "body") continue
|
|
245
|
+
// Handle Buffer values properly
|
|
246
|
+
let valueStr = value
|
|
247
|
+
if (Buffer.isBuffer(value)) {
|
|
248
|
+
// Use binary/latin1 encoding to preserve all byte values 0-255
|
|
249
|
+
valueStr = value.toString("binary")
|
|
250
|
+
}
|
|
251
|
+
allEntries.push({ key: key, line: `${key}: ${valueStr}` })
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const lines = []
|
|
255
|
+
|
|
256
|
+
if (isInline) {
|
|
257
|
+
// Inline parts without ao-types: sort ALL fields alphabetically including content-disposition
|
|
258
|
+
allEntries.push({
|
|
259
|
+
key: "content-disposition",
|
|
260
|
+
line: `content-disposition: ${disposition}`,
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
// Sort by key
|
|
264
|
+
allEntries.sort((a, b) => a.key.localeCompare(b.key))
|
|
265
|
+
|
|
266
|
+
// Extract the lines
|
|
267
|
+
lines.push(...allEntries.map(entry => entry.line))
|
|
268
|
+
} else {
|
|
269
|
+
// Regular parts: content-disposition first, then fields
|
|
270
|
+
lines.push(`content-disposition: ${disposition}`)
|
|
271
|
+
lines.push(...allEntries.map(entry => entry.line))
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Body handling
|
|
275
|
+
const body = bodyPart.body || ""
|
|
276
|
+
if (body) {
|
|
277
|
+
lines.push("") // Always add empty line before body
|
|
278
|
+
lines.push(body)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return lines.join(CRLF)
|
|
282
|
+
}
|
|
283
|
+
} else if (typeof bodyPart === "string" || Buffer.isBuffer(bodyPart)) {
|
|
284
|
+
return `content-disposition: ${disposition}${DOUBLE_CRLF}${bodyPart}`
|
|
285
|
+
}
|
|
286
|
+
return ""
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Parse multipart body
|
|
290
|
+
function parseMultipart(contentType, body) {
|
|
291
|
+
const boundaryMatch = contentType.match(/boundary="?([^";\s]+)"?/)
|
|
292
|
+
if (!boundaryMatch) return {}
|
|
293
|
+
|
|
294
|
+
const boundary = boundaryMatch[1]
|
|
295
|
+
const boundaryDelim = `--${boundary}`
|
|
296
|
+
const endBoundary = `--${boundary}--`
|
|
297
|
+
|
|
298
|
+
// Remove the final boundary terminator if present
|
|
299
|
+
let bodyContent = body
|
|
300
|
+
if (bodyContent.endsWith(endBoundary)) {
|
|
301
|
+
bodyContent = bodyContent.substring(0, bodyContent.lastIndexOf(endBoundary))
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const parts = bodyContent
|
|
305
|
+
.split(new RegExp(`\\r?\\n?${boundaryDelim}\\r?\\n`))
|
|
306
|
+
.filter(p => p && p.trim() && !p.startsWith("--"))
|
|
307
|
+
|
|
308
|
+
const result = {}
|
|
309
|
+
const bodyKeysList = []
|
|
310
|
+
|
|
311
|
+
for (const part of parts) {
|
|
312
|
+
const [headerBlock, ...bodyParts] = part.split(DOUBLE_CRLF)
|
|
313
|
+
let partBody = bodyParts.join(DOUBLE_CRLF)
|
|
314
|
+
|
|
315
|
+
// Remove trailing CRLF
|
|
316
|
+
partBody = partBody.replace(/\r?\n?$/, "")
|
|
317
|
+
|
|
318
|
+
const headers = {}
|
|
319
|
+
const headerLines = headerBlock.split(/\r?\n/)
|
|
320
|
+
for (const line of headerLines) {
|
|
321
|
+
const colonIndex = line.indexOf(": ")
|
|
322
|
+
if (colonIndex > 0) {
|
|
323
|
+
const name = line.substring(0, colonIndex).toLowerCase()
|
|
324
|
+
const value = line.substring(colonIndex + 2)
|
|
325
|
+
headers[name] = value
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const disposition = headers["content-disposition"]
|
|
330
|
+
if (!disposition) continue
|
|
331
|
+
|
|
332
|
+
let partName
|
|
333
|
+
if (disposition === "inline") {
|
|
334
|
+
partName = "body"
|
|
335
|
+
bodyKeysList.push("body")
|
|
336
|
+
} else {
|
|
337
|
+
const nameMatch = disposition.match(/name="([^"]+)"/)
|
|
338
|
+
partName = nameMatch ? nameMatch[1] : null
|
|
339
|
+
if (partName) {
|
|
340
|
+
// Add the top-level key for this part
|
|
341
|
+
const topLevelKey = partName.split("/")[0]
|
|
342
|
+
bodyKeysList.push(topLevelKey)
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (!partName) continue
|
|
347
|
+
|
|
348
|
+
const restHeaders = { ...headers }
|
|
349
|
+
delete restHeaders["content-disposition"]
|
|
350
|
+
|
|
351
|
+
if (Object.keys(restHeaders).length === 0) {
|
|
352
|
+
result[partName] = partBody
|
|
353
|
+
} else if (!partBody) {
|
|
354
|
+
result[partName] = restHeaders
|
|
355
|
+
} else {
|
|
356
|
+
result[partName] = { ...restHeaders, body: partBody }
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (bodyKeysList.length > 0) {
|
|
361
|
+
// Format as structured field list, preserving order and duplicates
|
|
362
|
+
result["body-keys"] = bodyKeysList.map(k => `"${k}"`).join(", ")
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return result
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Add content-digest header
|
|
369
|
+
function addContentDigest(msg) {
|
|
370
|
+
if (!msg.body) return msg
|
|
371
|
+
|
|
372
|
+
const hash = crypto.createHash("sha256")
|
|
373
|
+
|
|
374
|
+
// Handle both string and Buffer bodies
|
|
375
|
+
if (Buffer.isBuffer(msg.body)) {
|
|
376
|
+
hash.update(msg.body)
|
|
377
|
+
} else {
|
|
378
|
+
// For strings, use binary encoding to match how the multipart body is encoded
|
|
379
|
+
hash.update(msg.body, "binary")
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const digest = hash.digest("base64")
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
...msg,
|
|
386
|
+
"content-digest": `sha-256=:${digest}:`,
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Convert HTTP message to TABM
|
|
392
|
+
*/
|
|
393
|
+
export function httpsig_from(http) {
|
|
394
|
+
if (typeof http === "string") return http
|
|
395
|
+
|
|
396
|
+
const body = http.body || ""
|
|
397
|
+
const [inlinedFieldHdrs, inlinedKey] = inlineKey(http)
|
|
398
|
+
const headers = { ...http }
|
|
399
|
+
delete headers.body
|
|
400
|
+
delete headers["body-keys"]
|
|
401
|
+
|
|
402
|
+
const contentType = headers["content-type"]
|
|
403
|
+
let withBodyKeys = headers
|
|
404
|
+
|
|
405
|
+
// Parse multipart body if present
|
|
406
|
+
if (body && contentType && contentType.includes("multipart")) {
|
|
407
|
+
const parsed = parseMultipart(contentType, body)
|
|
408
|
+
|
|
409
|
+
// Handle the body-keys from the original HTTP message
|
|
410
|
+
if (http["body-keys"]) {
|
|
411
|
+
// Parse the existing body-keys and ensure they're in quoted format
|
|
412
|
+
const bodyKeys = http["body-keys"]
|
|
413
|
+
if (typeof bodyKeys === "string") {
|
|
414
|
+
if (!bodyKeys.includes('"')) {
|
|
415
|
+
// Convert unquoted format to quoted format
|
|
416
|
+
parsed["body-keys"] = bodyKeys
|
|
417
|
+
.split(/,\s*/)
|
|
418
|
+
.map(k => `"${k.trim()}"`)
|
|
419
|
+
.join(", ")
|
|
420
|
+
} else {
|
|
421
|
+
// Already quoted, use as-is
|
|
422
|
+
parsed["body-keys"] = bodyKeys
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
withBodyKeys = { ...headers, ...parsed }
|
|
428
|
+
|
|
429
|
+
// Convert flat structure to nested using flat.js
|
|
430
|
+
const flat = {}
|
|
431
|
+
for (const [key, value] of Object.entries(withBodyKeys)) {
|
|
432
|
+
if (key.includes("/")) {
|
|
433
|
+
flat[key] = value
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (Object.keys(flat).length > 0) {
|
|
438
|
+
// Use flat_from to convert flat structure to nested
|
|
439
|
+
const nested = flat_from(flat)
|
|
440
|
+
for (const [key, value] of Object.entries(nested)) {
|
|
441
|
+
withBodyKeys[key] = value
|
|
442
|
+
}
|
|
443
|
+
for (const key of Object.keys(flat)) {
|
|
444
|
+
delete withBodyKeys[key]
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
} else if (body) {
|
|
448
|
+
withBodyKeys[inlinedKey] = body
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Ungroup IDs
|
|
452
|
+
const withIds = ungroupIds(withBodyKeys)
|
|
453
|
+
|
|
454
|
+
// Remove signature-related headers and content-digest
|
|
455
|
+
const result = { ...withIds }
|
|
456
|
+
delete result.signature
|
|
457
|
+
delete result["signature-input"]
|
|
458
|
+
delete result.commitments
|
|
459
|
+
delete result["content-digest"]
|
|
460
|
+
|
|
461
|
+
// Extract hashpaths if any
|
|
462
|
+
for (const key of Object.keys(result)) {
|
|
463
|
+
if (key.startsWith("hashpath")) {
|
|
464
|
+
delete result[key]
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return result
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Convert TABM to HTTP message
|
|
473
|
+
*/
|
|
474
|
+
export function httpsig_to(tabm) {
|
|
475
|
+
if (typeof tabm === "string") return tabm
|
|
476
|
+
|
|
477
|
+
// Group IDs
|
|
478
|
+
const withGroupedIds = groupIds(tabm)
|
|
479
|
+
|
|
480
|
+
// Remove private and signature-related keys
|
|
481
|
+
const stripped = { ...withGroupedIds }
|
|
482
|
+
delete stripped.commitments
|
|
483
|
+
delete stripped.signature
|
|
484
|
+
delete stripped["signature-input"]
|
|
485
|
+
delete stripped.priv
|
|
486
|
+
|
|
487
|
+
const [inlineFieldHdrs, inlineKeyVal] = inlineKey(tabm)
|
|
488
|
+
|
|
489
|
+
// Check if this is a flat structure that should stay as headers
|
|
490
|
+
// A flat structure has no nested objects (maps)
|
|
491
|
+
const hasNestedMaps = Object.values(stripped).some(
|
|
492
|
+
value =>
|
|
493
|
+
typeof value === "object" &&
|
|
494
|
+
value !== null &&
|
|
495
|
+
!Array.isArray(value) &&
|
|
496
|
+
!Buffer.isBuffer(value)
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
// If it's just a flat map with strings/primitives, keep as headers
|
|
500
|
+
// This matches Erlang's behavior where flat maps don't become multipart
|
|
501
|
+
if (!hasNestedMaps) {
|
|
502
|
+
// For flat structures, just return with normalized keys
|
|
503
|
+
// This matches Erlang which returns the map unchanged
|
|
504
|
+
const result = { ...inlineFieldHdrs }
|
|
505
|
+
|
|
506
|
+
for (const [key, value] of Object.entries(stripped)) {
|
|
507
|
+
// Convert Buffers to strings if they're UTF-8 text
|
|
508
|
+
if (Buffer.isBuffer(value)) {
|
|
509
|
+
try {
|
|
510
|
+
const str = value.toString("utf8")
|
|
511
|
+
// Check if it's valid UTF-8 that can be safely converted
|
|
512
|
+
if (Buffer.from(str, "utf8").equals(value)) {
|
|
513
|
+
// Check if all characters are printable
|
|
514
|
+
let isPrintable = true
|
|
515
|
+
for (let i = 0; i < str.length; i++) {
|
|
516
|
+
const code = str.charCodeAt(i)
|
|
517
|
+
if (
|
|
518
|
+
!(code >= 32 && code <= 126) &&
|
|
519
|
+
code !== 9 &&
|
|
520
|
+
code !== 10 &&
|
|
521
|
+
code !== 13
|
|
522
|
+
) {
|
|
523
|
+
isPrintable = false
|
|
524
|
+
break
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
if (isPrintable) {
|
|
528
|
+
result[key] = str
|
|
529
|
+
} else {
|
|
530
|
+
result[key] = value
|
|
531
|
+
}
|
|
532
|
+
} else {
|
|
533
|
+
result[key] = value
|
|
534
|
+
}
|
|
535
|
+
} catch (e) {
|
|
536
|
+
result[key] = value
|
|
537
|
+
}
|
|
538
|
+
} else {
|
|
539
|
+
result[key] = value
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Handle inline body key - move data from inline key to body
|
|
544
|
+
if (inlineKeyVal && inlineKeyVal !== "body" && result[inlineKeyVal]) {
|
|
545
|
+
result.body = result[inlineKeyVal]
|
|
546
|
+
delete result[inlineKeyVal]
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// If there's a body, add content-digest
|
|
550
|
+
if (result.body) {
|
|
551
|
+
return addContentDigest(result)
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return result
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Original multipart logic for nested structures
|
|
558
|
+
const bodyMap = {}
|
|
559
|
+
const headers = { ...inlineFieldHdrs }
|
|
560
|
+
|
|
561
|
+
// Process each field - ao-types at top level should go to headers
|
|
562
|
+
for (const [key, value] of Object.entries(stripped)) {
|
|
563
|
+
if (key === "ao-types") {
|
|
564
|
+
// Top-level ao-types goes to headers only
|
|
565
|
+
// Convert Buffer to string if needed
|
|
566
|
+
if (Buffer.isBuffer(value)) {
|
|
567
|
+
headers[key] = value.toString("utf8")
|
|
568
|
+
} else {
|
|
569
|
+
headers[key] = value
|
|
570
|
+
}
|
|
571
|
+
} else if (key === "body" || key === inlineKeyVal) {
|
|
572
|
+
bodyMap[key === inlineKeyVal ? inlineKeyVal : "body"] = value
|
|
573
|
+
} else if (
|
|
574
|
+
typeof value === "object" &&
|
|
575
|
+
value !== null &&
|
|
576
|
+
!Array.isArray(value) &&
|
|
577
|
+
!Buffer.isBuffer(value)
|
|
578
|
+
) {
|
|
579
|
+
bodyMap[key] = value
|
|
580
|
+
} else if (
|
|
581
|
+
typeof value === "string" &&
|
|
582
|
+
value.length <= MAX_HEADER_LENGTH &&
|
|
583
|
+
key !== "ao-types"
|
|
584
|
+
) {
|
|
585
|
+
headers[normalizeKey(key)] = value
|
|
586
|
+
} else if (
|
|
587
|
+
Buffer.isBuffer(value) &&
|
|
588
|
+
value.length <= MAX_HEADER_LENGTH &&
|
|
589
|
+
key !== "ao-types"
|
|
590
|
+
) {
|
|
591
|
+
// Convert Buffers to strings for headers
|
|
592
|
+
const str = value.toString("utf8")
|
|
593
|
+
headers[normalizeKey(key)] = str
|
|
594
|
+
} else if (key !== "ao-types") {
|
|
595
|
+
// Only add to bodyMap if it's not ao-types
|
|
596
|
+
bodyMap[key] = value
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Handle body encoding
|
|
601
|
+
const groupedBodyMap = groupMaps(bodyMap)
|
|
602
|
+
|
|
603
|
+
if (Object.keys(groupedBodyMap).length === 0) {
|
|
604
|
+
return headers
|
|
605
|
+
} else if (
|
|
606
|
+
Object.keys(groupedBodyMap).length === 1 &&
|
|
607
|
+
groupedBodyMap[inlineKeyVal] &&
|
|
608
|
+
typeof groupedBodyMap[inlineKeyVal] === "string"
|
|
609
|
+
) {
|
|
610
|
+
const result = { ...headers, body: groupedBodyMap[inlineKeyVal] }
|
|
611
|
+
return addContentDigest(result)
|
|
612
|
+
} else {
|
|
613
|
+
// Multipart body
|
|
614
|
+
const parts = []
|
|
615
|
+
const bodyKeysList = []
|
|
616
|
+
|
|
617
|
+
const sortedEntries = Object.entries(groupedBodyMap).sort(([a], [b]) =>
|
|
618
|
+
a.localeCompare(b)
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
for (const [key, value] of sortedEntries) {
|
|
622
|
+
if (
|
|
623
|
+
typeof value === "object" &&
|
|
624
|
+
value !== null &&
|
|
625
|
+
Object.keys(value).length === 1 &&
|
|
626
|
+
"body" in value
|
|
627
|
+
) {
|
|
628
|
+
const encoded = encodeBodyPart(`${key}/body`, value, "body")
|
|
629
|
+
parts.push({
|
|
630
|
+
name: `${key}/body`,
|
|
631
|
+
body: encoded,
|
|
632
|
+
})
|
|
633
|
+
bodyKeysList.push(key)
|
|
634
|
+
} else {
|
|
635
|
+
const encoded = encodeBodyPart(key, value, inlineKeyVal)
|
|
636
|
+
parts.push({
|
|
637
|
+
name: key,
|
|
638
|
+
body: encoded,
|
|
639
|
+
})
|
|
640
|
+
bodyKeysList.push(key)
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const boundary = boundaryFromParts(parts)
|
|
645
|
+
|
|
646
|
+
const bodyParts = parts.map(p => `--${boundary}${CRLF}${p.body}`)
|
|
647
|
+
const finalBody = bodyParts.join(CRLF) + `${CRLF}--${boundary}--`
|
|
648
|
+
|
|
649
|
+
const result = {
|
|
650
|
+
...headers,
|
|
651
|
+
"body-keys": bodyKeysList.map(k => `"${k}"`).join(", "),
|
|
652
|
+
"content-type": `multipart/form-data; boundary="${boundary}"`,
|
|
653
|
+
body: finalBody,
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
return addContentDigest(result)
|
|
657
|
+
}
|
|
658
|
+
}
|