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/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
+ }