hbsig 0.3.1 → 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.
- package/.babelrc-cjs +5 -0
- package/.babelrc-esm +5 -0
- package/README.md +1 -0
- package/dist/package.json +39 -0
- package/make.js +36 -0
- package/package.json +16 -16
- package/src/bin_to_str.js +46 -0
- package/src/collect-body-keys.js +436 -0
- package/src/commit.js +219 -0
- package/src/encode-array-item.js +112 -0
- package/src/encode-utils.js +191 -0
- package/src/encode.js +1256 -0
- package/src/erl_json.js +292 -0
- package/src/erl_str.js +1144 -0
- package/src/flat.js +250 -0
- package/src/http-message-signatures/httpbis.js +438 -0
- package/src/http-message-signatures/index.js +4 -0
- package/src/http-message-signatures/structured-header.js +105 -0
- package/src/httpsig.js +866 -0
- package/src/id.js +459 -0
- package/src/index.js +13 -0
- package/src/nocrypto.js +4 -0
- package/src/parser.js +171 -0
- package/src/send-utils.js +1132 -0
- package/src/send.js +142 -0
- package/src/signer-utils.js +375 -0
- package/src/signer.js +312 -0
- package/src/structured.js +496 -0
- package/src/test.js +2 -0
- package/src/utils.js +29 -0
- package/test/commit.test.js +41 -0
- package/test/erl_json.test.js +8 -0
- package/test/flat.test.js +27 -0
- package/test/httpsig.test.js +31 -0
- package/test/id.test.js +114 -0
- package/test/lib/all_cases.js +408 -0
- package/test/lib/cases.js +408 -0
- package/test/lib/erl_json_cases.js +161 -0
- package/test/lib/flat_cases.js +189 -0
- package/test/lib/gen.js +528 -0
- package/test/lib/httpsig_cases.js +313 -0
- package/test/lib/structured_cases.js +222 -0
- package/test/lib/test-utils.js +399 -0
- package/test/signer.test.js +48 -0
- package/test/structured.test.js +35 -0
- /package/{cjs → dist/cjs}/bin_to_str.js +0 -0
- /package/{cjs → dist/cjs}/collect-body-keys.js +0 -0
- /package/{cjs → dist/cjs}/commit.js +0 -0
- /package/{cjs → dist/cjs}/encode-array-item.js +0 -0
- /package/{cjs → dist/cjs}/encode-utils.js +0 -0
- /package/{cjs → dist/cjs}/encode.js +0 -0
- /package/{cjs → dist/cjs}/erl_json.js +0 -0
- /package/{cjs → dist/cjs}/erl_str.js +0 -0
- /package/{cjs → dist/cjs}/flat.js +0 -0
- /package/{cjs → dist/cjs}/http-message-signatures/httpbis.js +0 -0
- /package/{cjs → dist/cjs}/http-message-signatures/index.js +0 -0
- /package/{cjs → dist/cjs}/http-message-signatures/structured-header.js +0 -0
- /package/{cjs → dist/cjs}/httpsig.js +0 -0
- /package/{cjs → dist/cjs}/id.js +0 -0
- /package/{cjs → dist/cjs}/index.js +0 -0
- /package/{cjs → dist/cjs}/nocrypto.js +0 -0
- /package/{cjs → dist/cjs}/parser.js +0 -0
- /package/{cjs → dist/cjs}/send-utils.js +0 -0
- /package/{cjs → dist/cjs}/send.js +0 -0
- /package/{cjs → dist/cjs}/signer-utils.js +0 -0
- /package/{cjs → dist/cjs}/signer.js +0 -0
- /package/{cjs → dist/cjs}/structured.js +0 -0
- /package/{cjs → dist/cjs}/test.js +0 -0
- /package/{cjs → dist/cjs}/utils.js +0 -0
- /package/{esm → dist/esm}/bin_to_str.js +0 -0
- /package/{esm → dist/esm}/collect-body-keys.js +0 -0
- /package/{esm → dist/esm}/commit.js +0 -0
- /package/{esm → dist/esm}/encode-array-item.js +0 -0
- /package/{esm → dist/esm}/encode-utils.js +0 -0
- /package/{esm → dist/esm}/encode.js +0 -0
- /package/{esm → dist/esm}/erl_json.js +0 -0
- /package/{esm → dist/esm}/erl_str.js +0 -0
- /package/{esm → dist/esm}/flat.js +0 -0
- /package/{esm → dist/esm}/http-message-signatures/httpbis.js +0 -0
- /package/{esm → dist/esm}/http-message-signatures/index.js +0 -0
- /package/{esm → dist/esm}/http-message-signatures/structured-header.js +0 -0
- /package/{esm → dist/esm}/httpsig.js +0 -0
- /package/{esm → dist/esm}/id.js +0 -0
- /package/{esm → dist/esm}/index.js +0 -0
- /package/{esm → dist/esm}/nocrypto.js +0 -0
- /package/{esm → dist/esm}/package.json +0 -0
- /package/{esm → dist/esm}/parser.js +0 -0
- /package/{esm → dist/esm}/send-utils.js +0 -0
- /package/{esm → dist/esm}/send.js +0 -0
- /package/{esm → dist/esm}/signer-utils.js +0 -0
- /package/{esm → dist/esm}/signer.js +0 -0
- /package/{esm → dist/esm}/structured.js +0 -0
- /package/{esm → dist/esm}/test.js +0 -0
- /package/{esm → dist/esm}/utils.js +0 -0
package/src/httpsig.js
ADDED
|
@@ -0,0 +1,866 @@
|
|
|
1
|
+
// httpsig.js - JavaScript implementation of HTTP Signature codec
|
|
2
|
+
|
|
3
|
+
import { hash } from "fast-sha256"
|
|
4
|
+
import { flat_from, flat_to } from "./flat.js"
|
|
5
|
+
import { structured_from as structuredFrom, structured_to as structuredTo } from "./structured.js"
|
|
6
|
+
|
|
7
|
+
const CRLF = "\r\n"
|
|
8
|
+
const DOUBLE_CRLF = CRLF + CRLF
|
|
9
|
+
const MAX_HEADER_LENGTH = 4096
|
|
10
|
+
|
|
11
|
+
// Helper to convert string to Uint8Array
|
|
12
|
+
function stringToBytes(str, encoding = "utf8") {
|
|
13
|
+
if (encoding === "binary") {
|
|
14
|
+
const bytes = new Uint8Array(str.length)
|
|
15
|
+
for (let i = 0; i < str.length; i++) {
|
|
16
|
+
bytes[i] = str.charCodeAt(i) & 0xff
|
|
17
|
+
}
|
|
18
|
+
return bytes
|
|
19
|
+
}
|
|
20
|
+
return new TextEncoder().encode(str)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Helper to convert bytes to base64url
|
|
24
|
+
function bytesToBase64url(bytes) {
|
|
25
|
+
const chars =
|
|
26
|
+
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
|
|
27
|
+
let result = ""
|
|
28
|
+
|
|
29
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
30
|
+
const a = bytes[i]
|
|
31
|
+
const b = i + 1 < bytes.length ? bytes[i + 1] : 0
|
|
32
|
+
const c = i + 2 < bytes.length ? bytes[i + 2] : 0
|
|
33
|
+
|
|
34
|
+
const combined = (a << 16) | (b << 8) | c
|
|
35
|
+
|
|
36
|
+
result += chars[(combined >> 18) & 0x3f]
|
|
37
|
+
result += chars[(combined >> 12) & 0x3f]
|
|
38
|
+
if (i + 1 < bytes.length) result += chars[(combined >> 6) & 0x3f]
|
|
39
|
+
if (i + 2 < bytes.length) result += chars[combined & 0x3f]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return result
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Helper to convert bytes to base64
|
|
46
|
+
function bytesToBase64(bytes) {
|
|
47
|
+
const chars =
|
|
48
|
+
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
|
49
|
+
let result = ""
|
|
50
|
+
|
|
51
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
52
|
+
const a = bytes[i]
|
|
53
|
+
const b = i + 1 < bytes.length ? bytes[i + 1] : 0
|
|
54
|
+
const c = i + 2 < bytes.length ? bytes[i + 2] : 0
|
|
55
|
+
|
|
56
|
+
const combined = (a << 16) | (b << 8) | c
|
|
57
|
+
|
|
58
|
+
result += chars[(combined >> 18) & 0x3f]
|
|
59
|
+
result += chars[(combined >> 12) & 0x3f]
|
|
60
|
+
result += i + 1 < bytes.length ? chars[(combined >> 6) & 0x3f] : "="
|
|
61
|
+
result += i + 2 < bytes.length ? chars[combined & 0x3f] : "="
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return result
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Helper to normalize keys (lowercase only, no underscore conversion)
|
|
68
|
+
function normalizeKey(key) {
|
|
69
|
+
if (typeof key === "string") {
|
|
70
|
+
return key.toLowerCase()
|
|
71
|
+
}
|
|
72
|
+
return String(key).toLowerCase()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Helper to check if a value is an ID (43 character base64url string)
|
|
76
|
+
function isId(value) {
|
|
77
|
+
return (
|
|
78
|
+
typeof value === "string" &&
|
|
79
|
+
value.length === 43 &&
|
|
80
|
+
/^[A-Za-z0-9_-]+$/.test(value)
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Helper to encode structured field dictionary
|
|
85
|
+
function encodeSfDict(dict) {
|
|
86
|
+
const items = []
|
|
87
|
+
for (const [key, value] of Object.entries(dict)) {
|
|
88
|
+
if (typeof value === "string") {
|
|
89
|
+
items.push(`${key}="${value}"`)
|
|
90
|
+
} else {
|
|
91
|
+
items.push(`${key}=${value}`)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return items.join(", ")
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Helper to parse structured field dictionary
|
|
98
|
+
function parseSfDict(str) {
|
|
99
|
+
const dict = {}
|
|
100
|
+
if (!str) return dict
|
|
101
|
+
|
|
102
|
+
const parts = str.split(/,\s*/)
|
|
103
|
+
for (const part of parts) {
|
|
104
|
+
const match = part.match(/^([^=]+)=(.+)$/)
|
|
105
|
+
if (match) {
|
|
106
|
+
const key = match[1].trim()
|
|
107
|
+
let value = match[2].trim()
|
|
108
|
+
if (value.startsWith('"') && value.endsWith('"')) {
|
|
109
|
+
value = value.slice(1, -1)
|
|
110
|
+
}
|
|
111
|
+
dict[key] = value
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return dict
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Helper to generate boundary from parts using fast-sha256
|
|
118
|
+
function boundaryFromParts(parts) {
|
|
119
|
+
const bodyBin = parts.map(p => p.body).join(CRLF)
|
|
120
|
+
const hashBytes = hash(stringToBytes(bodyBin, "binary"))
|
|
121
|
+
return bytesToBase64url(hashBytes)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Helper to determine inline key - matches Erlang's inline_key/2
|
|
125
|
+
function inlineKey(msg) {
|
|
126
|
+
// Check for ao-body-key (Erlang uses ao-body-key, not inline-body-key)
|
|
127
|
+
const aoBodyKey = msg["ao-body-key"]
|
|
128
|
+
if (aoBodyKey) {
|
|
129
|
+
return [{}, aoBodyKey]
|
|
130
|
+
}
|
|
131
|
+
if ("body" in msg) {
|
|
132
|
+
return [{}, "body"]
|
|
133
|
+
}
|
|
134
|
+
if ("data" in msg) {
|
|
135
|
+
return [{ "ao-body-key": "data" }, "data"]
|
|
136
|
+
}
|
|
137
|
+
return [{}, "body"]
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Group IDs into ao-ids field
|
|
141
|
+
function groupIds(map) {
|
|
142
|
+
const idDict = {}
|
|
143
|
+
const stripped = {}
|
|
144
|
+
|
|
145
|
+
for (const [key, value] of Object.entries(map)) {
|
|
146
|
+
if (isId(key) && typeof value === "string") {
|
|
147
|
+
// Store with lowercase key as in Erlang
|
|
148
|
+
idDict[key.toLowerCase()] = value
|
|
149
|
+
} else {
|
|
150
|
+
stripped[key] = value
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (Object.keys(idDict).length > 0) {
|
|
155
|
+
const items = []
|
|
156
|
+
for (const [k, v] of Object.entries(idDict)) {
|
|
157
|
+
items.push(`${k}="${v}"`)
|
|
158
|
+
}
|
|
159
|
+
stripped["ao-ids"] = items.join(", ")
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Return IDs as lowercase in the result since they will be normalized
|
|
163
|
+
// when processed as headers
|
|
164
|
+
for (const [k, v] of Object.entries(idDict)) {
|
|
165
|
+
stripped[k] = v
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return stripped
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Ungroup IDs from ao-ids field
|
|
172
|
+
function ungroupIds(msg) {
|
|
173
|
+
if (!msg["ao-ids"]) return msg
|
|
174
|
+
|
|
175
|
+
const result = { ...msg }
|
|
176
|
+
delete result["ao-ids"]
|
|
177
|
+
|
|
178
|
+
const dict = parseSfDict(msg["ao-ids"])
|
|
179
|
+
for (const [key, value] of Object.entries(dict)) {
|
|
180
|
+
// ao-ids keys are lowercase, use them as-is
|
|
181
|
+
result[key] = value
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return result
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Get the size of a map (matches Erlang's maps:size behavior)
|
|
188
|
+
// This counts ALL keys including ao-types - empty means literally {}
|
|
189
|
+
function mapSize(obj) {
|
|
190
|
+
if (typeof obj !== "object" || obj === null) return 0
|
|
191
|
+
return Object.keys(obj).length
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Group maps for body encoding - following Erlang logic exactly
|
|
195
|
+
function groupMaps(map, parent = "", top = {}) {
|
|
196
|
+
if (
|
|
197
|
+
typeof map !== "object" ||
|
|
198
|
+
map === null ||
|
|
199
|
+
Array.isArray(map) ||
|
|
200
|
+
Buffer.isBuffer(map)
|
|
201
|
+
) {
|
|
202
|
+
return top
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const flattened = {}
|
|
206
|
+
let newTop = { ...top }
|
|
207
|
+
|
|
208
|
+
// Process entries in sorted order for consistency
|
|
209
|
+
const entries = Object.entries(map).sort(([a], [b]) => a.localeCompare(b))
|
|
210
|
+
|
|
211
|
+
for (const [key, value] of entries) {
|
|
212
|
+
// Normalize keys to lowercase
|
|
213
|
+
const normKey = normalizeKey(key)
|
|
214
|
+
const flatK = parent ? `${parent}/${normKey}` : normKey
|
|
215
|
+
|
|
216
|
+
if (
|
|
217
|
+
typeof value === "object" &&
|
|
218
|
+
value !== null &&
|
|
219
|
+
!Array.isArray(value) &&
|
|
220
|
+
!Buffer.isBuffer(value)
|
|
221
|
+
) {
|
|
222
|
+
// Check size of the nested object (including metadata keys like ao-types)
|
|
223
|
+
// Empty means literally {} - a map with only ao-types is NOT empty
|
|
224
|
+
const size = mapSize(value)
|
|
225
|
+
|
|
226
|
+
if (size === 0) {
|
|
227
|
+
// Empty map (no data keys) - add empty-message marker
|
|
228
|
+
// This matches Erlang's group_maps behavior for empty maps
|
|
229
|
+
newTop[flatK] = { "ao-types": "empty-message" }
|
|
230
|
+
} else {
|
|
231
|
+
// Recursively process nested objects
|
|
232
|
+
newTop = groupMaps(value, flatK, newTop)
|
|
233
|
+
}
|
|
234
|
+
} else if (typeof value === "string" && value.length > MAX_HEADER_LENGTH) {
|
|
235
|
+
// Value too large for header, lift to top level
|
|
236
|
+
newTop[flatK] = value
|
|
237
|
+
} else if (Buffer.isBuffer(value) && value.length > MAX_HEADER_LENGTH) {
|
|
238
|
+
// Large buffers also get lifted to top level
|
|
239
|
+
newTop[flatK] = value
|
|
240
|
+
} else {
|
|
241
|
+
// Keep in current flattened map
|
|
242
|
+
flattened[normKey] = value
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (Object.keys(flattened).length === 0) {
|
|
247
|
+
return newTop
|
|
248
|
+
} else if (parent) {
|
|
249
|
+
// Add flattened map under parent key
|
|
250
|
+
return { ...newTop, [parent]: flattened }
|
|
251
|
+
} else {
|
|
252
|
+
// Merge flattened with top level
|
|
253
|
+
return { ...newTop, ...flattened }
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Helper to compute content-digest for a body value
|
|
258
|
+
function computePartDigest(bodyValue) {
|
|
259
|
+
let bodyBytes
|
|
260
|
+
if (Buffer.isBuffer(bodyValue)) {
|
|
261
|
+
bodyBytes = new Uint8Array(bodyValue)
|
|
262
|
+
} else if (typeof bodyValue === "string") {
|
|
263
|
+
bodyBytes = stringToBytes(bodyValue, "binary")
|
|
264
|
+
} else {
|
|
265
|
+
bodyBytes = stringToBytes(String(bodyValue), "binary")
|
|
266
|
+
}
|
|
267
|
+
const hashBytes = hash(bodyBytes)
|
|
268
|
+
return `sha-256=:${bytesToBase64(hashBytes)}:`
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Encode multipart body part
|
|
272
|
+
// NOTE: This matches Erlang's encode_body_part/4 which does NOT apply inline_key
|
|
273
|
+
// logic to nested parts. For nested maps, ALL fields become headers (except 'body'
|
|
274
|
+
// which becomes the part body). The inline_key logic is only for top-level messages.
|
|
275
|
+
function encodeBodyPart(partName, bodyPart, inlineKey) {
|
|
276
|
+
const disposition =
|
|
277
|
+
partName === inlineKey ? "inline" : `form-data;name="${partName}"`
|
|
278
|
+
|
|
279
|
+
if (
|
|
280
|
+
typeof bodyPart === "object" &&
|
|
281
|
+
bodyPart !== null &&
|
|
282
|
+
!Array.isArray(bodyPart) &&
|
|
283
|
+
!Buffer.isBuffer(bodyPart)
|
|
284
|
+
) {
|
|
285
|
+
// Collect all headers (everything except 'body' and 'priv')
|
|
286
|
+
const allEntries = []
|
|
287
|
+
|
|
288
|
+
for (const [key, value] of Object.entries(bodyPart)) {
|
|
289
|
+
if (key === "body" || key === "priv") continue
|
|
290
|
+
|
|
291
|
+
// Handle Buffer values properly
|
|
292
|
+
let valueStr = value
|
|
293
|
+
if (Buffer.isBuffer(value)) {
|
|
294
|
+
// Use binary/latin1 encoding to preserve all byte values 0-255
|
|
295
|
+
valueStr = value.toString("binary")
|
|
296
|
+
}
|
|
297
|
+
allEntries.push({ key: key, line: `${key}: ${valueStr}` })
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Add content-disposition
|
|
301
|
+
allEntries.push({
|
|
302
|
+
key: "content-disposition",
|
|
303
|
+
line: `content-disposition: ${disposition}`,
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
// Sort all entries by key alphabetically - matches Erlang behavior
|
|
307
|
+
allEntries.sort((a, b) => a.key.localeCompare(b.key))
|
|
308
|
+
|
|
309
|
+
// Build the lines
|
|
310
|
+
const lines = allEntries.map(entry => entry.line)
|
|
311
|
+
|
|
312
|
+
// Only the 'body' field (if present) becomes the part body
|
|
313
|
+
const body = bodyPart.body
|
|
314
|
+
if (body !== "" && body !== undefined && body !== null) {
|
|
315
|
+
lines.push("") // Always add empty line before body
|
|
316
|
+
lines.push(Buffer.isBuffer(body) ? body.toString("binary") : String(body))
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return lines.join(CRLF)
|
|
320
|
+
} else if (typeof bodyPart === "string" || Buffer.isBuffer(bodyPart)) {
|
|
321
|
+
// Use binary/latin1 encoding to preserve byte values 0-255
|
|
322
|
+
const bodyStr = Buffer.isBuffer(bodyPart) ? bodyPart.toString("binary") : bodyPart
|
|
323
|
+
return `content-disposition: ${disposition}${DOUBLE_CRLF}${bodyStr}`
|
|
324
|
+
}
|
|
325
|
+
return ""
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Helper to detect if a Buffer contains binary data
|
|
329
|
+
function isBinaryData(buf) {
|
|
330
|
+
// Check first 512 bytes for binary indicators
|
|
331
|
+
const checkLength = Math.min(buf.length, 512)
|
|
332
|
+
|
|
333
|
+
// Any null byte means binary
|
|
334
|
+
for (let i = 0; i < checkLength; i++) {
|
|
335
|
+
if (buf[i] === 0) return true
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Count non-text bytes
|
|
339
|
+
let controlCount = 0
|
|
340
|
+
let highByteCount = 0
|
|
341
|
+
|
|
342
|
+
for (let i = 0; i < checkLength; i++) {
|
|
343
|
+
const byte = buf[i]
|
|
344
|
+
// Non-printable chars (except CR/LF/TAB)
|
|
345
|
+
if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) {
|
|
346
|
+
controlCount++
|
|
347
|
+
}
|
|
348
|
+
// High byte range
|
|
349
|
+
if (byte > 127) {
|
|
350
|
+
highByteCount++
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// If more than 5% are control chars, likely binary
|
|
355
|
+
if (controlCount / checkLength > 0.05) return true
|
|
356
|
+
|
|
357
|
+
// If more than 20% are high bytes, likely binary
|
|
358
|
+
if (highByteCount / checkLength > 0.2) return true
|
|
359
|
+
|
|
360
|
+
return false
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Parse multipart body - handles binary data in headers
|
|
364
|
+
function parseMultipart(contentType, body) {
|
|
365
|
+
const boundaryMatch = contentType.match(/boundary="?([^";\s]+)"?/)
|
|
366
|
+
if (!boundaryMatch) return {}
|
|
367
|
+
|
|
368
|
+
const boundary = boundaryMatch[1]
|
|
369
|
+
const boundaryDelim = `--${boundary}`
|
|
370
|
+
const endBoundary = `--${boundary}--`
|
|
371
|
+
|
|
372
|
+
// Use binary encoding to preserve all bytes as character codes 0-255
|
|
373
|
+
let bodyContent = typeof body === "string" ? body : body.toString("binary")
|
|
374
|
+
if (bodyContent.endsWith(endBoundary)) {
|
|
375
|
+
bodyContent = bodyContent.substring(0, bodyContent.lastIndexOf(endBoundary))
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const parts = bodyContent
|
|
379
|
+
.split(new RegExp(`\\r?\\n?${boundaryDelim}\\r?\\n`))
|
|
380
|
+
.filter(p => p && p.trim() && !p.startsWith("--"))
|
|
381
|
+
|
|
382
|
+
const result = {}
|
|
383
|
+
const bodyKeysList = []
|
|
384
|
+
|
|
385
|
+
for (const part of parts) {
|
|
386
|
+
// First split to find the headers/body boundary (double CRLF)
|
|
387
|
+
const headerBodySplit = part.indexOf(DOUBLE_CRLF)
|
|
388
|
+
let headerBlock, partBody
|
|
389
|
+
|
|
390
|
+
if (headerBodySplit !== -1) {
|
|
391
|
+
headerBlock = part.substring(0, headerBodySplit)
|
|
392
|
+
partBody = part.substring(headerBodySplit + DOUBLE_CRLF.length)
|
|
393
|
+
// Remove trailing CRLF from body
|
|
394
|
+
partBody = partBody.replace(/\r?\n?$/, "")
|
|
395
|
+
} else {
|
|
396
|
+
headerBlock = part
|
|
397
|
+
partBody = ""
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const headers = {}
|
|
401
|
+
|
|
402
|
+
// Parse headers more carefully to handle binary data
|
|
403
|
+
let currentPos = 0
|
|
404
|
+
while (currentPos < headerBlock.length) {
|
|
405
|
+
// Find the next colon to identify a potential header
|
|
406
|
+
let colonPos = headerBlock.indexOf(": ", currentPos)
|
|
407
|
+
|
|
408
|
+
if (colonPos === -1) {
|
|
409
|
+
// No more headers
|
|
410
|
+
break
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Look backwards from colon to find the start of this line
|
|
414
|
+
let lineStart = currentPos
|
|
415
|
+
let searchBack = colonPos - 1
|
|
416
|
+
while (searchBack >= currentPos) {
|
|
417
|
+
if (headerBlock[searchBack] === "\n") {
|
|
418
|
+
lineStart = searchBack + 1
|
|
419
|
+
break
|
|
420
|
+
}
|
|
421
|
+
searchBack--
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Extract the header name
|
|
425
|
+
const name = headerBlock
|
|
426
|
+
.substring(lineStart, colonPos)
|
|
427
|
+
.trim()
|
|
428
|
+
.toLowerCase()
|
|
429
|
+
|
|
430
|
+
// Check if this looks like a valid header name
|
|
431
|
+
if (!/^[a-zA-Z0-9-]+$/.test(name) || name.length > 50) {
|
|
432
|
+
// Not a valid header, skip past this colon
|
|
433
|
+
currentPos = colonPos + 2
|
|
434
|
+
continue
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Start of value is after ": "
|
|
438
|
+
let valueStart = colonPos + 2
|
|
439
|
+
|
|
440
|
+
// Find the end of this header's value by looking for the next valid header
|
|
441
|
+
let valueEnd = headerBlock.length
|
|
442
|
+
let searchPos = valueStart
|
|
443
|
+
|
|
444
|
+
while (searchPos < headerBlock.length) {
|
|
445
|
+
let nextNewline = headerBlock.indexOf("\n", searchPos)
|
|
446
|
+
if (nextNewline === -1) break
|
|
447
|
+
|
|
448
|
+
let nextLineStart = nextNewline + 1
|
|
449
|
+
if (nextLineStart >= headerBlock.length) break
|
|
450
|
+
|
|
451
|
+
// Check if next line starts with a header pattern
|
|
452
|
+
let nextColon = headerBlock.indexOf(": ", nextLineStart)
|
|
453
|
+
|
|
454
|
+
if (nextColon > nextLineStart && nextColon < nextLineStart + 50) {
|
|
455
|
+
let possibleHeaderName = headerBlock
|
|
456
|
+
.substring(nextLineStart, nextColon)
|
|
457
|
+
.trim()
|
|
458
|
+
|
|
459
|
+
// Must be valid header name format
|
|
460
|
+
if (/^[a-zA-Z0-9-]+$/.test(possibleHeaderName)) {
|
|
461
|
+
valueEnd = nextNewline
|
|
462
|
+
break
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
searchPos = nextNewline + 1
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Extract the value - valueEnd points to \n which is NOT part of the value
|
|
470
|
+
let value = headerBlock.substring(valueStart, valueEnd)
|
|
471
|
+
|
|
472
|
+
// Determine if this is binary data FIRST (before trimming)
|
|
473
|
+
const isBinary =
|
|
474
|
+
value.length > 0 && isBinaryData(Buffer.from(value, "binary"))
|
|
475
|
+
|
|
476
|
+
if (isBinary) {
|
|
477
|
+
// Binary data: only remove trailing \r if it's part of CRLF separator
|
|
478
|
+
// (when valueEnd points to \n and value ends with \r)
|
|
479
|
+
if (
|
|
480
|
+
valueEnd < headerBlock.length &&
|
|
481
|
+
headerBlock[valueEnd] === "\n" &&
|
|
482
|
+
value.endsWith("\r")
|
|
483
|
+
) {
|
|
484
|
+
value = value.substring(0, value.length - 1)
|
|
485
|
+
}
|
|
486
|
+
headers[name] = Buffer.from(value, "binary")
|
|
487
|
+
} else {
|
|
488
|
+
// Text data: remove all trailing CRLF/LF (these are line separators, not data)
|
|
489
|
+
value = value.replace(/[\r\n]+$/, "")
|
|
490
|
+
headers[name] = value
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Move to the end of this header's value
|
|
494
|
+
currentPos = valueEnd
|
|
495
|
+
if (currentPos < headerBlock.length && headerBlock[currentPos] === "\n") {
|
|
496
|
+
currentPos++
|
|
497
|
+
}
|
|
498
|
+
if (currentPos < headerBlock.length && headerBlock[currentPos] === "\r") {
|
|
499
|
+
currentPos++
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const disposition = headers["content-disposition"]
|
|
504
|
+
if (!disposition) continue
|
|
505
|
+
|
|
506
|
+
let partName
|
|
507
|
+
if (disposition === "inline") {
|
|
508
|
+
// This is the inline part
|
|
509
|
+
partName = "body"
|
|
510
|
+
bodyKeysList.push("body")
|
|
511
|
+
|
|
512
|
+
// Extract all headers from inline part as top-level fields
|
|
513
|
+
const restHeaders = { ...headers }
|
|
514
|
+
delete restHeaders["content-disposition"]
|
|
515
|
+
|
|
516
|
+
// Add each header from the inline part to the top level of result
|
|
517
|
+
for (const [key, value] of Object.entries(restHeaders)) {
|
|
518
|
+
result[key] = value
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// If there's body content in the inline part, add it as 'body'
|
|
522
|
+
if (partBody) {
|
|
523
|
+
// Keep as string unless it's binary data
|
|
524
|
+
result[partName] = isBinaryData(Buffer.from(partBody, "binary"))
|
|
525
|
+
? Buffer.from(partBody, "binary")
|
|
526
|
+
: partBody
|
|
527
|
+
}
|
|
528
|
+
} else {
|
|
529
|
+
// Handle named form-data parts
|
|
530
|
+
const nameMatch = disposition.match(/name="([^"]+)"/)
|
|
531
|
+
partName = nameMatch ? nameMatch[1] : null
|
|
532
|
+
if (partName) {
|
|
533
|
+
const topLevelKey = partName.split("/")[0]
|
|
534
|
+
bodyKeysList.push(topLevelKey)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (!partName) continue
|
|
538
|
+
|
|
539
|
+
const restHeaders = { ...headers }
|
|
540
|
+
delete restHeaders["content-disposition"]
|
|
541
|
+
|
|
542
|
+
if (Object.keys(restHeaders).length === 0) {
|
|
543
|
+
// Keep as string unless it's binary data
|
|
544
|
+
result[partName] = isBinaryData(Buffer.from(partBody, "binary"))
|
|
545
|
+
? Buffer.from(partBody, "binary")
|
|
546
|
+
: partBody
|
|
547
|
+
} else if (!partBody) {
|
|
548
|
+
// ao-types should stay with this part, not be extracted
|
|
549
|
+
result[partName] = restHeaders
|
|
550
|
+
} else {
|
|
551
|
+
// Keep as string unless it's binary data
|
|
552
|
+
result[partName] = {
|
|
553
|
+
...restHeaders,
|
|
554
|
+
body: isBinaryData(Buffer.from(partBody, "binary"))
|
|
555
|
+
? Buffer.from(partBody, "binary")
|
|
556
|
+
: partBody,
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (bodyKeysList.length > 0) {
|
|
563
|
+
result["body-keys"] = bodyKeysList.map(k => `"${k}"`).join(", ")
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
return result
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Add content-digest header using fast-sha256
|
|
570
|
+
function addContentDigest(msg) {
|
|
571
|
+
if (!msg.body) return msg
|
|
572
|
+
|
|
573
|
+
let bodyBytes
|
|
574
|
+
// Handle both string and Buffer bodies
|
|
575
|
+
if (Buffer.isBuffer(msg.body)) {
|
|
576
|
+
bodyBytes = new Uint8Array(msg.body)
|
|
577
|
+
} else {
|
|
578
|
+
// For strings, use binary encoding to match how the multipart body is encoded
|
|
579
|
+
bodyBytes = stringToBytes(msg.body, "binary")
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const hashBytes = hash(bodyBytes)
|
|
583
|
+
const digest = bytesToBase64(hashBytes)
|
|
584
|
+
|
|
585
|
+
return {
|
|
586
|
+
...msg,
|
|
587
|
+
"content-digest": `sha-256=:${digest}:`,
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Convert HTTP message to TABM
|
|
593
|
+
*/
|
|
594
|
+
export function httpsig_from(http) {
|
|
595
|
+
if (typeof http === "string") return http
|
|
596
|
+
|
|
597
|
+
const body = http.body || ""
|
|
598
|
+
const [inlinedFieldHdrs, inlinedKey] = inlineKey(http)
|
|
599
|
+
const headers = { ...http }
|
|
600
|
+
delete headers.body
|
|
601
|
+
delete headers["body-keys"]
|
|
602
|
+
|
|
603
|
+
const contentType = headers["content-type"]
|
|
604
|
+
let withBodyKeys = headers
|
|
605
|
+
|
|
606
|
+
// Parse multipart body if present
|
|
607
|
+
if (body && contentType && contentType.includes("multipart")) {
|
|
608
|
+
const parsed = parseMultipart(contentType, body)
|
|
609
|
+
|
|
610
|
+
// Handle the body-keys from the original HTTP message
|
|
611
|
+
if (http["body-keys"]) {
|
|
612
|
+
// Parse the existing body-keys and ensure they're in quoted format
|
|
613
|
+
const bodyKeys = http["body-keys"]
|
|
614
|
+
if (typeof bodyKeys === "string") {
|
|
615
|
+
if (!bodyKeys.includes('"')) {
|
|
616
|
+
// Convert unquoted format to quoted format
|
|
617
|
+
parsed["body-keys"] = bodyKeys
|
|
618
|
+
.split(/,\s*/)
|
|
619
|
+
.map(k => `"${k.trim()}"`)
|
|
620
|
+
.join(", ")
|
|
621
|
+
} else {
|
|
622
|
+
// Already quoted, use as-is
|
|
623
|
+
parsed["body-keys"] = bodyKeys
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
withBodyKeys = { ...headers, ...parsed }
|
|
629
|
+
|
|
630
|
+
// Convert flat structure to nested using flat.js
|
|
631
|
+
const flat = {}
|
|
632
|
+
const nonFlat = {}
|
|
633
|
+
for (const [key, value] of Object.entries(withBodyKeys)) {
|
|
634
|
+
if (key.includes("/")) {
|
|
635
|
+
flat[key] = value
|
|
636
|
+
} else {
|
|
637
|
+
nonFlat[key] = value
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if (Object.keys(flat).length > 0) {
|
|
642
|
+
// Merge non-flat keys into flat for processing
|
|
643
|
+
// This ensures flat_from can see existing objects like results: { "ao-types": "..." }
|
|
644
|
+
const combined = { ...nonFlat, ...flat }
|
|
645
|
+
|
|
646
|
+
// Use flat_from to convert flat structure to nested
|
|
647
|
+
const nested = flat_from(combined)
|
|
648
|
+
|
|
649
|
+
// The nested result already has everything merged
|
|
650
|
+
withBodyKeys = nested
|
|
651
|
+
}
|
|
652
|
+
} else if (body) {
|
|
653
|
+
withBodyKeys[inlinedKey] = body
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Ungroup IDs
|
|
657
|
+
const withIds = ungroupIds(withBodyKeys)
|
|
658
|
+
|
|
659
|
+
// Remove signature-related headers and content-digest
|
|
660
|
+
const result = { ...withIds }
|
|
661
|
+
delete result.signature
|
|
662
|
+
delete result["signature-input"]
|
|
663
|
+
delete result.commitments
|
|
664
|
+
delete result["content-digest"]
|
|
665
|
+
|
|
666
|
+
// Extract hashpaths if any
|
|
667
|
+
for (const key of Object.keys(result)) {
|
|
668
|
+
if (key.startsWith("hashpath")) {
|
|
669
|
+
delete result[key]
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
return result
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Convert TABM to HTTP message
|
|
678
|
+
* Implements bundle mode like Erlang's dev_codec_httpsig_conv:to/3 with bundle=true
|
|
679
|
+
*/
|
|
680
|
+
export function httpsig_to(tabm) {
|
|
681
|
+
if (typeof tabm === "string") return tabm
|
|
682
|
+
|
|
683
|
+
// Bundle logic: TABM → structured → TABM
|
|
684
|
+
// This matches Erlang's behavior when bundle=true:
|
|
685
|
+
// 1. Convert TABM to structured@1.0 (interprets ao-types, decodes to native types)
|
|
686
|
+
// 2. Convert back to TABM (re-encodes with ao-types)
|
|
687
|
+
const structured = structuredTo(tabm)
|
|
688
|
+
const bundledTabm = structuredFrom(structured)
|
|
689
|
+
|
|
690
|
+
// Group IDs
|
|
691
|
+
const withGroupedIds = groupIds(bundledTabm)
|
|
692
|
+
|
|
693
|
+
// Remove private and signature-related keys
|
|
694
|
+
const stripped = { ...withGroupedIds }
|
|
695
|
+
delete stripped.commitments
|
|
696
|
+
delete stripped.signature
|
|
697
|
+
delete stripped["signature-input"]
|
|
698
|
+
delete stripped.priv
|
|
699
|
+
|
|
700
|
+
const [inlineFieldHdrs, inlineKeyVal] = inlineKey(tabm)
|
|
701
|
+
|
|
702
|
+
// Check if this is a flat structure that should stay as headers
|
|
703
|
+
// A flat structure has no nested objects (maps), excluding:
|
|
704
|
+
// - Arrays (JS arrays, not numbered maps)
|
|
705
|
+
// - Buffers
|
|
706
|
+
// Note: List-encoded maps (numbered maps with .="list") ARE nested maps
|
|
707
|
+
// and should trigger multipart encoding, matching Erlang's behavior
|
|
708
|
+
const hasNestedMaps = Object.values(stripped).some(value => {
|
|
709
|
+
// Not an object
|
|
710
|
+
if (typeof value !== "object" || value === null) return false
|
|
711
|
+
// Arrays and Buffers are not nested maps
|
|
712
|
+
if (Array.isArray(value) || Buffer.isBuffer(value)) return false
|
|
713
|
+
// Any other object (including list-encoded maps) is a nested map
|
|
714
|
+
return true
|
|
715
|
+
})
|
|
716
|
+
|
|
717
|
+
// If it's just a flat map with strings/primitives, keep as headers
|
|
718
|
+
// This matches Erlang's behavior where flat maps don't become multipart
|
|
719
|
+
if (!hasNestedMaps) {
|
|
720
|
+
// For flat structures, just return with normalized keys
|
|
721
|
+
const result = { ...inlineFieldHdrs, ...stripped }
|
|
722
|
+
|
|
723
|
+
// Handle inline body key - move data from inline key to body
|
|
724
|
+
if (inlineKeyVal && inlineKeyVal !== "body" && result[inlineKeyVal]) {
|
|
725
|
+
result.body = result[inlineKeyVal]
|
|
726
|
+
delete result[inlineKeyVal]
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// If the only field is ao-types (no actual data), return empty object
|
|
730
|
+
// This matches Erlang's behavior where ao-types-only messages become empty
|
|
731
|
+
const dataKeys = Object.keys(result).filter(k =>
|
|
732
|
+
k !== "ao-types" &&
|
|
733
|
+
k !== "ao-ids" &&
|
|
734
|
+
k !== "inline-body-key" &&
|
|
735
|
+
k !== "ao-body-key"
|
|
736
|
+
)
|
|
737
|
+
if (dataKeys.length === 0) {
|
|
738
|
+
return {}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// If there's a non-empty body, add content-digest
|
|
742
|
+
// Erlang doesn't add content-digest for empty bodies (<<>>)
|
|
743
|
+
if (result.body) {
|
|
744
|
+
const bodyIsEmpty = Buffer.isBuffer(result.body)
|
|
745
|
+
? result.body.length === 0
|
|
746
|
+
: (typeof result.body === "string" && result.body.length === 0)
|
|
747
|
+
|
|
748
|
+
if (!bodyIsEmpty) {
|
|
749
|
+
return addContentDigest(result)
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
return result
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Original multipart logic for nested structures
|
|
757
|
+
const bodyMap = {}
|
|
758
|
+
const headers = { ...inlineFieldHdrs }
|
|
759
|
+
|
|
760
|
+
// Process each field - ao-types at top level should go to headers
|
|
761
|
+
for (const [key, value] of Object.entries(stripped)) {
|
|
762
|
+
if (key === "ao-types") {
|
|
763
|
+
// Top-level ao-types goes to headers only
|
|
764
|
+
// Keep as Buffer if it's a Buffer, otherwise use as-is
|
|
765
|
+
headers[key] = value
|
|
766
|
+
} else if (key === "body" || key === inlineKeyVal) {
|
|
767
|
+
bodyMap[key === inlineKeyVal ? inlineKeyVal : "body"] = value
|
|
768
|
+
} else if (
|
|
769
|
+
typeof value === "object" &&
|
|
770
|
+
value !== null &&
|
|
771
|
+
!Array.isArray(value) &&
|
|
772
|
+
!Buffer.isBuffer(value)
|
|
773
|
+
) {
|
|
774
|
+
bodyMap[key] = value
|
|
775
|
+
} else if (
|
|
776
|
+
typeof value === "string" &&
|
|
777
|
+
value.length <= MAX_HEADER_LENGTH &&
|
|
778
|
+
key !== "ao-types"
|
|
779
|
+
) {
|
|
780
|
+
headers[normalizeKey(key)] = value
|
|
781
|
+
} else if (
|
|
782
|
+
Buffer.isBuffer(value) &&
|
|
783
|
+
value.length <= MAX_HEADER_LENGTH &&
|
|
784
|
+
key !== "ao-types"
|
|
785
|
+
) {
|
|
786
|
+
// Keep buffers as buffers for headers
|
|
787
|
+
headers[normalizeKey(key)] = value
|
|
788
|
+
} else if (key !== "ao-types") {
|
|
789
|
+
// Only add to bodyMap if it's not ao-types
|
|
790
|
+
bodyMap[key] = value
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// Handle body encoding
|
|
795
|
+
const groupedBodyMap = groupMaps(bodyMap)
|
|
796
|
+
|
|
797
|
+
if (Object.keys(groupedBodyMap).length === 0) {
|
|
798
|
+
return headers
|
|
799
|
+
} else if (
|
|
800
|
+
Object.keys(groupedBodyMap).length === 1 &&
|
|
801
|
+
groupedBodyMap[inlineKeyVal] &&
|
|
802
|
+
typeof groupedBodyMap[inlineKeyVal] === "string"
|
|
803
|
+
) {
|
|
804
|
+
const result = { ...headers, body: groupedBodyMap[inlineKeyVal] }
|
|
805
|
+
return addContentDigest(result)
|
|
806
|
+
} else {
|
|
807
|
+
// Multipart body
|
|
808
|
+
const parts = []
|
|
809
|
+
const bodyKeysList = []
|
|
810
|
+
|
|
811
|
+
const sortedEntries = Object.entries(groupedBodyMap).sort(([a], [b]) =>
|
|
812
|
+
a.localeCompare(b)
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
for (const [key, value] of sortedEntries) {
|
|
816
|
+
if (
|
|
817
|
+
typeof value === "object" &&
|
|
818
|
+
value !== null &&
|
|
819
|
+
Object.keys(value).length === 1 &&
|
|
820
|
+
"body" in value
|
|
821
|
+
) {
|
|
822
|
+
const encoded = encodeBodyPart(`${key}/body`, value, "body")
|
|
823
|
+
parts.push({
|
|
824
|
+
name: `${key}/body`,
|
|
825
|
+
body: encoded,
|
|
826
|
+
})
|
|
827
|
+
bodyKeysList.push(key)
|
|
828
|
+
} else {
|
|
829
|
+
const encoded = encodeBodyPart(key, value, inlineKeyVal)
|
|
830
|
+
parts.push({
|
|
831
|
+
name: key,
|
|
832
|
+
body: encoded,
|
|
833
|
+
})
|
|
834
|
+
bodyKeysList.push(key)
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const boundary = boundaryFromParts(parts)
|
|
839
|
+
|
|
840
|
+
const bodyParts = parts.map(p => `--${boundary}${CRLF}${p.body}`)
|
|
841
|
+
const finalBody = bodyParts.join(CRLF) + `${CRLF}--${boundary}--`
|
|
842
|
+
|
|
843
|
+
const result = {
|
|
844
|
+
...headers,
|
|
845
|
+
// Note: body-keys is NOT included in httpsig output - it's only used for parsing
|
|
846
|
+
"content-type": `multipart/form-data; boundary="${boundary}"`,
|
|
847
|
+
body: finalBody,
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
return addContentDigest(result)
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Convert structured message to flat format
|
|
856
|
+
*/
|
|
857
|
+
export function structured_to(msg) {
|
|
858
|
+
return msg
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Convert flat format to structured message
|
|
863
|
+
*/
|
|
864
|
+
export function structured_from(msg) {
|
|
865
|
+
return msg
|
|
866
|
+
}
|