wao 0.21.2 → 0.22.0
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/adaptor-base.js +183 -162
- package/cjs/ao.js +1 -1
- package/cjs/aoconnect-base.js +70 -73
- package/cjs/bao.js +1 -1
- package/cjs/hb.js +896 -272
- package/cjs/hyperbeam-server.js +46 -0
- package/cjs/hyperbeam.js +86 -0
- package/cjs/server.js +1 -1
- package/cjs/signer.js +1016 -0
- package/cjs/signer2.js +1012 -0
- package/cjs/test.js +3 -1
- package/cjs/utils.js +39 -3
- package/esm/adaptor-base.js +4 -2
- package/esm/ao.js +1 -1
- package/esm/aoconnect-base.js +12 -12
- package/esm/ar.js +1 -1
- package/esm/bao.js +1 -1
- package/esm/bar.js +3 -0
- package/esm/gql.js +2 -1
- package/esm/hb.js +232 -43
- package/esm/helpers.js +1 -1
- package/esm/hyperbeam-server.js +21 -0
- package/esm/hyperbeam.js +57 -0
- package/esm/server.js +4 -1
- package/esm/signer.js +769 -0
- package/esm/signer2.js +762 -0
- package/esm/test.js +2 -0
- package/esm/utils.js +31 -3
- package/package.json +3 -2
package/esm/signer.js
ADDED
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
import base64url from "base64url"
|
|
2
|
+
import crypto from "crypto"
|
|
3
|
+
import { httpbis } from "http-message-signatures"
|
|
4
|
+
import { parseItem, serializeList } from "structured-headers"
|
|
5
|
+
const { verifyMessage } = httpbis
|
|
6
|
+
const {
|
|
7
|
+
augmentHeaders,
|
|
8
|
+
createSignatureBase,
|
|
9
|
+
createSigningParameters,
|
|
10
|
+
formatSignatureBase,
|
|
11
|
+
} = httpbis
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Convert value to Buffer
|
|
15
|
+
*/
|
|
16
|
+
const toView = value => {
|
|
17
|
+
if (ArrayBuffer.isView(value)) {
|
|
18
|
+
return Buffer.from(value.buffer, value.byteOffset, value.byteLength)
|
|
19
|
+
} else if (typeof value === "string") {
|
|
20
|
+
return base64url.toBuffer(value)
|
|
21
|
+
}
|
|
22
|
+
throw new Error(
|
|
23
|
+
"Value must be Uint8Array, ArrayBuffer, or base64url-encoded string"
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Generate HTTP signature name from address
|
|
29
|
+
*/
|
|
30
|
+
const httpSigName = address => {
|
|
31
|
+
const decoded = base64url.toBuffer(address)
|
|
32
|
+
const hexString = [...decoded.subarray(1, 9)]
|
|
33
|
+
.map(byte => byte.toString(16).padStart(2, "0"))
|
|
34
|
+
.join("")
|
|
35
|
+
return `http-sig-${hexString}`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Join URL parts
|
|
40
|
+
*/
|
|
41
|
+
const joinUrl = ({ url, path }) => {
|
|
42
|
+
// If path is already a full URL, return it as-is
|
|
43
|
+
if (path.startsWith("http://") || path.startsWith("https://")) {
|
|
44
|
+
return path
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Otherwise, join the base URL with the path
|
|
48
|
+
return url.endsWith("/") ? url.slice(0, -1) + path : url + path
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* HyperBEAM Encoding Logic
|
|
53
|
+
*/
|
|
54
|
+
const MAX_HEADER_LENGTH = 4096
|
|
55
|
+
|
|
56
|
+
async function hasNewline(value) {
|
|
57
|
+
if (typeof value === "string") return value.includes("\n")
|
|
58
|
+
if (value instanceof Blob) {
|
|
59
|
+
value = await value.text()
|
|
60
|
+
return value.includes("\n")
|
|
61
|
+
}
|
|
62
|
+
if (isBytes(value)) return Buffer.from(value).includes("\n")
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function sha256(data) {
|
|
67
|
+
return crypto.subtle.digest("SHA-256", data)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isBytes(value) {
|
|
71
|
+
return value instanceof ArrayBuffer || ArrayBuffer.isView(value)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isPojo(value) {
|
|
75
|
+
return (
|
|
76
|
+
!isBytes(value) &&
|
|
77
|
+
!Array.isArray(value) &&
|
|
78
|
+
!(value instanceof Blob) &&
|
|
79
|
+
typeof value === "object" &&
|
|
80
|
+
value !== null
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function hbEncodeValue(value) {
|
|
85
|
+
if (isBytes(value)) {
|
|
86
|
+
if (value.byteLength === 0) return hbEncodeValue("")
|
|
87
|
+
return [undefined, value]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (typeof value === "string") {
|
|
91
|
+
if (value.length === 0) return [undefined, "empty-binary"]
|
|
92
|
+
return [undefined, value]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (Array.isArray(value)) {
|
|
96
|
+
if (value.length === 0) return ["empty-list", undefined]
|
|
97
|
+
// For structured fields, just join the string values
|
|
98
|
+
const encoded = value
|
|
99
|
+
.map(v => {
|
|
100
|
+
if (typeof v === "string") {
|
|
101
|
+
// Escape quotes and backslashes
|
|
102
|
+
const escaped = v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
|
103
|
+
return `"${escaped}"`
|
|
104
|
+
}
|
|
105
|
+
return `"${String(v)}"`
|
|
106
|
+
})
|
|
107
|
+
.join(", ")
|
|
108
|
+
return ["list", encoded]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (typeof value === "number") {
|
|
112
|
+
if (!Number.isInteger(value)) return ["float", `${value}`]
|
|
113
|
+
return ["integer", String(value)]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (typeof value === "symbol") {
|
|
117
|
+
return ["atom", value.description]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
throw new Error(`Cannot encode value: ${value.toString()}`)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function hbEncodeLift(obj, parent = "", top = {}) {
|
|
124
|
+
const [flattened, types] = Object.entries({ ...obj }).reduce(
|
|
125
|
+
(acc, [key, value]) => {
|
|
126
|
+
// For nested paths, preserve casing. For top-level, also preserve casing
|
|
127
|
+
const storageKey = parent ? `${parent}/${key}` : key
|
|
128
|
+
|
|
129
|
+
// skip nullish values
|
|
130
|
+
if (value == null) return acc
|
|
131
|
+
|
|
132
|
+
// list of objects
|
|
133
|
+
if (Array.isArray(value) && value.some(isPojo)) {
|
|
134
|
+
value = value.reduce(
|
|
135
|
+
(indexedObj, v, idx) => Object.assign(indexedObj, { [idx]: v }),
|
|
136
|
+
{}
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// first/second lift object
|
|
141
|
+
if (isPojo(value)) {
|
|
142
|
+
hbEncodeLift(value, storageKey, top)
|
|
143
|
+
return acc
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// leaf encode value
|
|
147
|
+
const [type, encoded] = hbEncodeValue(value)
|
|
148
|
+
if (encoded !== undefined) {
|
|
149
|
+
if (Buffer.from(String(encoded)).byteLength > MAX_HEADER_LENGTH) {
|
|
150
|
+
top[storageKey] = String(encoded)
|
|
151
|
+
} else {
|
|
152
|
+
// Preserve the original key casing
|
|
153
|
+
const httpKey = key
|
|
154
|
+
if (type === "integer" && typeof value === "number") {
|
|
155
|
+
acc[0][httpKey] = String(value)
|
|
156
|
+
} else {
|
|
157
|
+
acc[0][httpKey] = encoded
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (type) {
|
|
162
|
+
// Store type with lowercase key for ao-types dictionary
|
|
163
|
+
acc[1][key.toLowerCase()] = type
|
|
164
|
+
}
|
|
165
|
+
return acc
|
|
166
|
+
},
|
|
167
|
+
[{}, {}]
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
if (Object.keys(flattened).length === 0) return top
|
|
171
|
+
|
|
172
|
+
if (Object.keys(types).length > 0) {
|
|
173
|
+
// Format as structured fields dictionary
|
|
174
|
+
const aoTypeItems = Object.entries(types).map(([key, value]) => {
|
|
175
|
+
const safeKey = key
|
|
176
|
+
.toLowerCase()
|
|
177
|
+
.replace(
|
|
178
|
+
/[^a-z0-9_-]/g,
|
|
179
|
+
c => "%" + c.charCodeAt(0).toString(16).padStart(2, "0")
|
|
180
|
+
)
|
|
181
|
+
return `${safeKey}="${value}"`
|
|
182
|
+
})
|
|
183
|
+
aoTypeItems.sort()
|
|
184
|
+
const aoTypes = aoTypeItems.join(", ")
|
|
185
|
+
|
|
186
|
+
if (Buffer.from(aoTypes).byteLength > MAX_HEADER_LENGTH) {
|
|
187
|
+
const flatK = parent ? `${parent}/ao-types` : "ao-types"
|
|
188
|
+
top[flatK] = aoTypes
|
|
189
|
+
} else {
|
|
190
|
+
flattened["ao-types"] = aoTypes
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (parent) {
|
|
195
|
+
top[parent] = flattened
|
|
196
|
+
} else {
|
|
197
|
+
Object.assign(top, flattened)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return top
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function encodePart(name, { headers, body }) {
|
|
204
|
+
const parts = Object.entries(Object.fromEntries(headers)).reduce(
|
|
205
|
+
(acc, [name, value]) => {
|
|
206
|
+
acc.push(`${name}: `, value, "\r\n")
|
|
207
|
+
return acc
|
|
208
|
+
},
|
|
209
|
+
[`content-disposition: form-data;name="${name}"\r\n`]
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
if (body) parts.push("\r\n", body)
|
|
213
|
+
|
|
214
|
+
return new Blob(parts)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function encode(obj = {}) {
|
|
218
|
+
if (Object.keys(obj).length === 0) return
|
|
219
|
+
|
|
220
|
+
// Keep reference to original object for data field
|
|
221
|
+
const originalObj = obj
|
|
222
|
+
const flattened = hbEncodeLift(obj)
|
|
223
|
+
|
|
224
|
+
const bodyKeys = []
|
|
225
|
+
const headerKeys = []
|
|
226
|
+
|
|
227
|
+
// Process all flattened keys
|
|
228
|
+
await Promise.all(
|
|
229
|
+
Object.keys(flattened).map(async key => {
|
|
230
|
+
const value = flattened[key]
|
|
231
|
+
|
|
232
|
+
if (isPojo(value)) {
|
|
233
|
+
const subPart = await encode(value)
|
|
234
|
+
if (!subPart) return
|
|
235
|
+
|
|
236
|
+
bodyKeys.push(key)
|
|
237
|
+
flattened[key] = encodePart(key, subPart)
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Check if this should be a body field
|
|
242
|
+
const valueStr = String(value)
|
|
243
|
+
if (
|
|
244
|
+
(await hasNewline(valueStr)) ||
|
|
245
|
+
key.includes("/") ||
|
|
246
|
+
Buffer.from(valueStr).byteLength > MAX_HEADER_LENGTH
|
|
247
|
+
) {
|
|
248
|
+
bodyKeys.push(key)
|
|
249
|
+
flattened[key] = new Blob([
|
|
250
|
+
`content-disposition: form-data;name="${key}"\r\n\r\n`,
|
|
251
|
+
value,
|
|
252
|
+
])
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// It's a header
|
|
257
|
+
headerKeys.push(key)
|
|
258
|
+
})
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
// Build headers object with all header keys
|
|
262
|
+
const headers = {}
|
|
263
|
+
headerKeys.forEach(key => {
|
|
264
|
+
headers[key] = flattened[key]
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
// Special handling for data and body fields
|
|
268
|
+
if ("data" in originalObj && !bodyKeys.includes("data")) {
|
|
269
|
+
bodyKeys.push("data")
|
|
270
|
+
delete headers["data"] // Remove from headers if it was there
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if ("body" in originalObj && !bodyKeys.includes("body")) {
|
|
274
|
+
bodyKeys.push("body")
|
|
275
|
+
delete headers["body"] // Remove from headers if it was there
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
let body = undefined
|
|
279
|
+
if (bodyKeys.length > 0) {
|
|
280
|
+
if (bodyKeys.length === 1) {
|
|
281
|
+
// If there is only one element, promote it to be the full body
|
|
282
|
+
const bodyKey = bodyKeys[0]
|
|
283
|
+
body = new Blob([originalObj[bodyKey] || flattened[bodyKey]])
|
|
284
|
+
headers["inline-body-key"] = bodyKey
|
|
285
|
+
} else {
|
|
286
|
+
// Multiple body fields - create multipart
|
|
287
|
+
const bodyParts = await Promise.all(
|
|
288
|
+
bodyKeys.map(name => {
|
|
289
|
+
if (flattened[name] instanceof Blob) {
|
|
290
|
+
return flattened[name].arrayBuffer()
|
|
291
|
+
}
|
|
292
|
+
// For raw values, create a blob
|
|
293
|
+
return new Blob([originalObj[name] || ""]).arrayBuffer()
|
|
294
|
+
})
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
const base = new Blob(
|
|
298
|
+
bodyParts.flatMap((p, i, arr) =>
|
|
299
|
+
i < arr.length - 1 ? [p, "\r\n"] : [p]
|
|
300
|
+
)
|
|
301
|
+
)
|
|
302
|
+
const hash = await sha256(await base.arrayBuffer())
|
|
303
|
+
const boundary = base64url.encode(Buffer.from(hash))
|
|
304
|
+
|
|
305
|
+
const blobParts = bodyParts.flatMap(p => [`--${boundary}\r\n`, p, "\r\n"])
|
|
306
|
+
blobParts.push(`--${boundary}--`)
|
|
307
|
+
|
|
308
|
+
headers["content-type"] = `multipart/form-data; boundary="${boundary}"`
|
|
309
|
+
body = new Blob(blobParts)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (body) {
|
|
313
|
+
const finalContent = await body.arrayBuffer()
|
|
314
|
+
const contentDigest = await sha256(finalContent)
|
|
315
|
+
const base64 = base64url.toBase64(base64url.encode(contentDigest))
|
|
316
|
+
|
|
317
|
+
// Use lowercase to match what's in the other headers
|
|
318
|
+
headers["content-digest"] = `sha-256=:${base64}:`
|
|
319
|
+
headers["content-length"] = String(finalContent.byteLength)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return { headers, body }
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Create HTTP signer wrapper
|
|
328
|
+
*/
|
|
329
|
+
const toHttpSigner = signer => {
|
|
330
|
+
const params = ["alg", "keyid"].sort()
|
|
331
|
+
|
|
332
|
+
return async ({ request, fields }) => {
|
|
333
|
+
let signatureBase
|
|
334
|
+
let signatureInput
|
|
335
|
+
let createCalled = false
|
|
336
|
+
|
|
337
|
+
const create = injected => {
|
|
338
|
+
createCalled = true
|
|
339
|
+
|
|
340
|
+
const { publicKey, alg = "rsa-pss-sha512" } = injected
|
|
341
|
+
|
|
342
|
+
const publicKeyBuffer = toView(publicKey)
|
|
343
|
+
|
|
344
|
+
const signingParameters = createSigningParameters({
|
|
345
|
+
params,
|
|
346
|
+
paramValues: {
|
|
347
|
+
keyid: base64url.encode(publicKeyBuffer),
|
|
348
|
+
alg,
|
|
349
|
+
},
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
const signatureBaseArray = createSignatureBase({ fields }, request)
|
|
353
|
+
signatureInput = serializeList([
|
|
354
|
+
[
|
|
355
|
+
signatureBaseArray.map(([item]) => parseItem(item)),
|
|
356
|
+
signingParameters,
|
|
357
|
+
],
|
|
358
|
+
])
|
|
359
|
+
|
|
360
|
+
signatureBaseArray.push(['"@signature-params"', [signatureInput]])
|
|
361
|
+
signatureBase = formatSignatureBase(signatureBaseArray)
|
|
362
|
+
|
|
363
|
+
return new TextEncoder().encode(signatureBase)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const result = await signer(create, "httpsig")
|
|
367
|
+
|
|
368
|
+
if (!createCalled) {
|
|
369
|
+
throw new Error(
|
|
370
|
+
"create() must be invoked in order to construct the data to sign"
|
|
371
|
+
)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (!result.signature || !result.address) {
|
|
375
|
+
throw new Error("Signer must return signature and address")
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const signatureBuffer = toView(result.signature)
|
|
379
|
+
const signedHeaders = augmentHeaders(
|
|
380
|
+
request.headers,
|
|
381
|
+
signatureBuffer,
|
|
382
|
+
signatureInput,
|
|
383
|
+
httpSigName(result.address)
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
// Only lowercase the signature headers
|
|
387
|
+
const finalHeaders = {}
|
|
388
|
+
for (const [key, value] of Object.entries(signedHeaders)) {
|
|
389
|
+
if (key === "Signature" || key === "Signature-Input") {
|
|
390
|
+
finalHeaders[key.toLowerCase()] = value
|
|
391
|
+
} else {
|
|
392
|
+
finalHeaders[key] = value
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
...request,
|
|
398
|
+
headers: finalHeaders,
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Create the main request function that creates signed messages locally
|
|
405
|
+
*
|
|
406
|
+
* @param {Object} config - Configuration object
|
|
407
|
+
* @param {Function} config.signer - Signer function
|
|
408
|
+
* @param {string} [config.HB_URL='http://relay.ao-hb.xyz'] - Base URL
|
|
409
|
+
* @returns {Function} Request function that takes tags and returns signed message
|
|
410
|
+
*/
|
|
411
|
+
export function createRequest(config) {
|
|
412
|
+
const { signer, url = "http://localhost:10001" } = config
|
|
413
|
+
|
|
414
|
+
if (!signer) {
|
|
415
|
+
throw new Error("Signer is required for mainnet mode")
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return async function request(fields) {
|
|
419
|
+
const { path = "/relay/process", method = "POST", ...restFields } = fields
|
|
420
|
+
|
|
421
|
+
// Add default AO fields
|
|
422
|
+
const aoFields = { ...restFields }
|
|
423
|
+
|
|
424
|
+
// Use the HyperBEAM encode function
|
|
425
|
+
const encoded = await encode(aoFields)
|
|
426
|
+
|
|
427
|
+
// If no encoding needed, create minimal structure
|
|
428
|
+
const headersObj = encoded ? encoded.headers : {}
|
|
429
|
+
const body = encoded ? encoded.body : undefined
|
|
430
|
+
|
|
431
|
+
const _url = joinUrl({ url, path })
|
|
432
|
+
|
|
433
|
+
// Add Content-Length if body exists
|
|
434
|
+
if (body && !headersObj["content-length"]) {
|
|
435
|
+
const bodySize = body.size || body.byteLength || 0
|
|
436
|
+
if (bodySize > 0) {
|
|
437
|
+
headersObj["content-length"] = String(bodySize)
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Create lowercase headers for signing
|
|
442
|
+
const lowercaseHeaders = {}
|
|
443
|
+
for (const [key, value] of Object.entries(headersObj)) {
|
|
444
|
+
lowercaseHeaders[key.toLowerCase()] = value
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Get all header keys for signing (lowercase)
|
|
448
|
+
const signingFields = Object.keys(lowercaseHeaders)
|
|
449
|
+
|
|
450
|
+
// Sign the request with lowercase headers
|
|
451
|
+
const signedRequest = await toHttpSigner(signer)({
|
|
452
|
+
request: { url: _url, method, headers: lowercaseHeaders },
|
|
453
|
+
fields: signingFields,
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
// Build final headers: use original casing for all headers except signature headers
|
|
457
|
+
const finalHeaders = {}
|
|
458
|
+
|
|
459
|
+
// First, add all original headers with their original casing
|
|
460
|
+
for (const [key, value] of Object.entries(headersObj)) {
|
|
461
|
+
finalHeaders[key] = value
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Then add the signature headers (which should be lowercase)
|
|
465
|
+
finalHeaders["signature"] = signedRequest.headers["signature"]
|
|
466
|
+
finalHeaders["signature-input"] = signedRequest.headers["signature-input"]
|
|
467
|
+
|
|
468
|
+
// Return the signed message
|
|
469
|
+
const result = {
|
|
470
|
+
url: _url,
|
|
471
|
+
method,
|
|
472
|
+
headers: finalHeaders,
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Only add body if it exists
|
|
476
|
+
if (body) {
|
|
477
|
+
result.body = body
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return result
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Utility function to extract the message ID from a signed message
|
|
485
|
+
* Based on the original code's hash calculation
|
|
486
|
+
*/
|
|
487
|
+
async function getMessageId(signedMessage) {
|
|
488
|
+
// Extract signature from the Signature header
|
|
489
|
+
const signatureHeader =
|
|
490
|
+
signedMessage.headers.Signature || signedMessage.headers.signature
|
|
491
|
+
const match = signatureHeader.match(/Signature:\s*'http-sig-[^:]+:([^']+)'/)
|
|
492
|
+
const signature = match ? match[1] : null
|
|
493
|
+
|
|
494
|
+
if (!signature) {
|
|
495
|
+
throw new Error("Could not extract signature from headers")
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Hash the signature to get message ID
|
|
499
|
+
const encoder = new TextEncoder()
|
|
500
|
+
const data = encoder.encode(signature)
|
|
501
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", data)
|
|
502
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
|
503
|
+
const hashBase64 = btoa(String.fromCharCode(...hashArray))
|
|
504
|
+
|
|
505
|
+
return hashBase64
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export async function send(signedMsg, fetchImpl = fetch) {
|
|
509
|
+
const fetchOptions = {
|
|
510
|
+
method: signedMsg.method,
|
|
511
|
+
headers: signedMsg.headers,
|
|
512
|
+
redirect: "follow",
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Only add body if it exists and method supports it
|
|
516
|
+
if (
|
|
517
|
+
signedMsg.body !== undefined &&
|
|
518
|
+
signedMsg.method !== "GET" &&
|
|
519
|
+
signedMsg.method !== "HEAD"
|
|
520
|
+
) {
|
|
521
|
+
fetchOptions.body = signedMsg.body
|
|
522
|
+
}
|
|
523
|
+
const response = await fetchImpl(signedMsg.url, fetchOptions)
|
|
524
|
+
|
|
525
|
+
if (response.status >= 400) {
|
|
526
|
+
throw new Error(`${response.status}: ${await response.text()}`)
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
return {
|
|
530
|
+
headers: response.headers,
|
|
531
|
+
body: await response.text(),
|
|
532
|
+
status: response.status,
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Convert JWK modulus (n) to PEM format public key
|
|
538
|
+
* @param {Buffer} nBuffer - The modulus buffer
|
|
539
|
+
* @returns {string} PEM formatted public key
|
|
540
|
+
*/
|
|
541
|
+
function jwkModulusToPem(nBuffer) {
|
|
542
|
+
// RSA public key with standard exponent
|
|
543
|
+
const rsaPublicKey = crypto.createPublicKey({
|
|
544
|
+
key: {
|
|
545
|
+
kty: "RSA",
|
|
546
|
+
n: base64url.encode(nBuffer),
|
|
547
|
+
e: "AQAB", // Standard exponent 65537
|
|
548
|
+
},
|
|
549
|
+
format: "jwk",
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
return rsaPublicKey.export({ type: "spki", format: "pem" })
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Extract signature name from headers
|
|
557
|
+
* @param {Object} headers - Request headers
|
|
558
|
+
* @returns {string|null} Signature name or null
|
|
559
|
+
*/
|
|
560
|
+
function extractSignatureName(headers) {
|
|
561
|
+
const signatureHeader = headers["signature"] || headers["Signature"]
|
|
562
|
+
if (!signatureHeader) return null
|
|
563
|
+
|
|
564
|
+
// Extract signature name (e.g., "http-sig-xxxxxxxx")
|
|
565
|
+
// Handle both "name:" and "name=" formats
|
|
566
|
+
const match = signatureHeader.match(/^([^:=]+)[:=]/)
|
|
567
|
+
return match ? match[1] : null
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Extract public key from signature-input header
|
|
572
|
+
* @param {Object} headers - Request headers
|
|
573
|
+
* @param {string} [signatureName] - Optional signature name to look for
|
|
574
|
+
* @returns {Buffer|null} Public key buffer or null
|
|
575
|
+
*/
|
|
576
|
+
function extractPublicKeyFromHeaders(headers, signatureName) {
|
|
577
|
+
const signatureInput =
|
|
578
|
+
headers["signature-input"] || headers["Signature-Input"]
|
|
579
|
+
if (!signatureInput) return null
|
|
580
|
+
|
|
581
|
+
// If we have a signature name, look for its specific keyid
|
|
582
|
+
let keyidMatch
|
|
583
|
+
if (signatureName) {
|
|
584
|
+
// The signature-input format is: signatureName=(...);alg="...";keyid="..."
|
|
585
|
+
// We need to match after the signature name
|
|
586
|
+
const signatureSection = signatureInput.substring(
|
|
587
|
+
signatureInput.indexOf(signatureName)
|
|
588
|
+
)
|
|
589
|
+
keyidMatch = signatureSection.match(/keyid="([^"]+)"/)
|
|
590
|
+
} else {
|
|
591
|
+
// General keyid match
|
|
592
|
+
keyidMatch = signatureInput.match(/keyid="([^"]+)"/)
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (!keyidMatch) return null
|
|
596
|
+
|
|
597
|
+
try {
|
|
598
|
+
return base64url.toBuffer(keyidMatch[1])
|
|
599
|
+
} catch (error) {
|
|
600
|
+
return null
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Verify an HTTP signed message using http-message-signatures
|
|
606
|
+
*
|
|
607
|
+
* @param {Object} signedMessage - The signed message to verify
|
|
608
|
+
* @param {string} signedMessage.url - Request URL
|
|
609
|
+
* @param {string} signedMessage.method - HTTP method
|
|
610
|
+
* @param {Object} signedMessage.headers - Headers including signature
|
|
611
|
+
* @param {string} [signedMessage.body] - Request body
|
|
612
|
+
* @param {string|Buffer} [publicKey] - Optional public key (if not provided, extracts from keyid)
|
|
613
|
+
* @returns {Object} Verification result
|
|
614
|
+
*/
|
|
615
|
+
export async function verify(signedMessage, publicKey) {
|
|
616
|
+
try {
|
|
617
|
+
const { url, method, headers, body } = signedMessage
|
|
618
|
+
|
|
619
|
+
// Determine which public key to use
|
|
620
|
+
let keyLookup
|
|
621
|
+
|
|
622
|
+
if (publicKey) {
|
|
623
|
+
// Use provided public key
|
|
624
|
+
const pem =
|
|
625
|
+
typeof publicKey === "string" ? publicKey : jwkModulusToPem(publicKey)
|
|
626
|
+
|
|
627
|
+
keyLookup = async keyId => {
|
|
628
|
+
return {
|
|
629
|
+
id: keyId,
|
|
630
|
+
algs: ["rsa-pss-sha512", "rsa-pss-sha256", "rsa-v1_5-sha256"],
|
|
631
|
+
verify: async (data, signature, parameters) => {
|
|
632
|
+
const verifier = crypto.createVerify(
|
|
633
|
+
`RSA-SHA${parameters.alg.includes("512") ? "512" : "256"}`
|
|
634
|
+
)
|
|
635
|
+
verifier.update(data)
|
|
636
|
+
|
|
637
|
+
if (parameters.alg.startsWith("rsa-pss")) {
|
|
638
|
+
return verifier.verify(
|
|
639
|
+
{
|
|
640
|
+
key: pem,
|
|
641
|
+
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
|
|
642
|
+
saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST,
|
|
643
|
+
},
|
|
644
|
+
signature
|
|
645
|
+
)
|
|
646
|
+
} else {
|
|
647
|
+
return verifier.verify(pem, signature)
|
|
648
|
+
}
|
|
649
|
+
},
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
} else {
|
|
653
|
+
// Extract public key from keyid
|
|
654
|
+
const signatureName = extractSignatureName(headers)
|
|
655
|
+
const extractedKey = extractPublicKeyFromHeaders(headers, signatureName)
|
|
656
|
+
if (!extractedKey) {
|
|
657
|
+
return {
|
|
658
|
+
valid: false,
|
|
659
|
+
error: "No public key provided and none found in signature",
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const pem = jwkModulusToPem(extractedKey)
|
|
664
|
+
|
|
665
|
+
keyLookup = async keyId => {
|
|
666
|
+
// The library might pass the keyId in different formats, so be flexible
|
|
667
|
+
return {
|
|
668
|
+
id: keyId,
|
|
669
|
+
algs: ["rsa-pss-sha512", "rsa-pss-sha256", "rsa-v1_5-sha256"],
|
|
670
|
+
verify: async (data, signature, parameters) => {
|
|
671
|
+
try {
|
|
672
|
+
const verifier = crypto.createVerify(
|
|
673
|
+
`RSA-SHA${parameters.alg.includes("512") ? "512" : "256"}`
|
|
674
|
+
)
|
|
675
|
+
verifier.update(data)
|
|
676
|
+
|
|
677
|
+
let verified
|
|
678
|
+
if (parameters.alg.startsWith("rsa-pss")) {
|
|
679
|
+
verified = verifier.verify(
|
|
680
|
+
{
|
|
681
|
+
key: pem,
|
|
682
|
+
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
|
|
683
|
+
saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST,
|
|
684
|
+
},
|
|
685
|
+
signature
|
|
686
|
+
)
|
|
687
|
+
} else {
|
|
688
|
+
verified = verifier.verify(pem, signature)
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
return verified
|
|
692
|
+
} catch (error) {
|
|
693
|
+
console.error("Verification error:", error)
|
|
694
|
+
return false
|
|
695
|
+
}
|
|
696
|
+
},
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Create request object for verification
|
|
702
|
+
const request = {
|
|
703
|
+
method,
|
|
704
|
+
url,
|
|
705
|
+
headers: { ...headers },
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// Extract additional info from headers
|
|
709
|
+
const signatureName = extractSignatureName(headers)
|
|
710
|
+
const extractedPublicKey = extractPublicKeyFromHeaders(
|
|
711
|
+
headers,
|
|
712
|
+
signatureName
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
// Extract algorithm from signature-input
|
|
716
|
+
const signatureInputHeader =
|
|
717
|
+
headers["signature-input"] || headers["Signature-Input"]
|
|
718
|
+
const algMatch = signatureInputHeader?.match(/alg="([^"]+)"/)
|
|
719
|
+
const algorithm = algMatch ? algMatch[1] : undefined
|
|
720
|
+
|
|
721
|
+
// Verify using the library
|
|
722
|
+
let verified = false
|
|
723
|
+
let verificationError = null
|
|
724
|
+
|
|
725
|
+
try {
|
|
726
|
+
const verificationResult = await verifyMessage(
|
|
727
|
+
{
|
|
728
|
+
keyLookup,
|
|
729
|
+
requiredFields: [], // Don't require specific fields
|
|
730
|
+
},
|
|
731
|
+
request
|
|
732
|
+
)
|
|
733
|
+
// If we get here without throwing, verification succeeded
|
|
734
|
+
verified = true
|
|
735
|
+
} catch (verifyError) {
|
|
736
|
+
// Verification failed
|
|
737
|
+
verificationError = verifyError.message
|
|
738
|
+
verified = false
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
return {
|
|
742
|
+
valid: true, // The signature format is valid
|
|
743
|
+
verified, // Whether the cryptographic verification passed
|
|
744
|
+
signatureName,
|
|
745
|
+
keyId: extractedPublicKey
|
|
746
|
+
? base64url.encode(extractedPublicKey)
|
|
747
|
+
: undefined,
|
|
748
|
+
algorithm,
|
|
749
|
+
publicKeyFromHeader: extractedPublicKey,
|
|
750
|
+
...(verificationError && { error: verificationError }),
|
|
751
|
+
}
|
|
752
|
+
} catch (error) {
|
|
753
|
+
return {
|
|
754
|
+
valid: false,
|
|
755
|
+
error: error.message,
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Extract public key from a signed message
|
|
762
|
+
*
|
|
763
|
+
* @param {Object} signedMessage - The signed message
|
|
764
|
+
* @returns {Buffer|null} The public key buffer or null
|
|
765
|
+
*/
|
|
766
|
+
function extractPublicKeyFromMessage(signedMessage) {
|
|
767
|
+
const signatureName = extractSignatureName(signedMessage.headers)
|
|
768
|
+
return extractPublicKeyFromHeaders(signedMessage.headers, signatureName)
|
|
769
|
+
}
|