hbsig 0.3.2 → 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.
Files changed (95) hide show
  1. package/.babelrc-cjs +5 -0
  2. package/.babelrc-esm +5 -0
  3. package/README.md +1 -0
  4. package/dist/package.json +39 -0
  5. package/make.js +36 -0
  6. package/package.json +16 -17
  7. package/src/bin_to_str.js +46 -0
  8. package/src/collect-body-keys.js +436 -0
  9. package/src/commit.js +219 -0
  10. package/src/encode-array-item.js +112 -0
  11. package/src/encode-utils.js +191 -0
  12. package/src/encode.js +1256 -0
  13. package/src/erl_json.js +292 -0
  14. package/src/erl_str.js +1144 -0
  15. package/src/flat.js +250 -0
  16. package/src/http-message-signatures/httpbis.js +438 -0
  17. package/src/http-message-signatures/index.js +4 -0
  18. package/src/http-message-signatures/structured-header.js +105 -0
  19. package/src/httpsig.js +866 -0
  20. package/src/id.js +459 -0
  21. package/src/index.js +13 -0
  22. package/src/nocrypto.js +4 -0
  23. package/src/parser.js +171 -0
  24. package/src/send-utils.js +1132 -0
  25. package/src/send.js +142 -0
  26. package/src/signer-utils.js +375 -0
  27. package/src/signer.js +312 -0
  28. package/src/structured.js +496 -0
  29. package/src/test.js +2 -0
  30. package/src/utils.js +29 -0
  31. package/test/commit.test.js +41 -0
  32. package/test/erl_json.test.js +8 -0
  33. package/test/flat.test.js +27 -0
  34. package/test/httpsig.test.js +31 -0
  35. package/test/id.test.js +114 -0
  36. package/test/lib/all_cases.js +408 -0
  37. package/test/lib/cases.js +408 -0
  38. package/test/lib/erl_json_cases.js +161 -0
  39. package/test/lib/flat_cases.js +189 -0
  40. package/test/lib/gen.js +528 -0
  41. package/test/lib/httpsig_cases.js +313 -0
  42. package/test/lib/structured_cases.js +222 -0
  43. package/test/lib/test-utils.js +399 -0
  44. package/test/signer.test.js +48 -0
  45. package/test/structured.test.js +35 -0
  46. package/bin/install-deps +0 -0
  47. /package/{cjs → dist/cjs}/bin_to_str.js +0 -0
  48. /package/{cjs → dist/cjs}/collect-body-keys.js +0 -0
  49. /package/{cjs → dist/cjs}/commit.js +0 -0
  50. /package/{cjs → dist/cjs}/encode-array-item.js +0 -0
  51. /package/{cjs → dist/cjs}/encode-utils.js +0 -0
  52. /package/{cjs → dist/cjs}/encode.js +0 -0
  53. /package/{cjs → dist/cjs}/erl_json.js +0 -0
  54. /package/{cjs → dist/cjs}/erl_str.js +0 -0
  55. /package/{cjs → dist/cjs}/flat.js +0 -0
  56. /package/{cjs → dist/cjs}/http-message-signatures/httpbis.js +0 -0
  57. /package/{cjs → dist/cjs}/http-message-signatures/index.js +0 -0
  58. /package/{cjs → dist/cjs}/http-message-signatures/structured-header.js +0 -0
  59. /package/{cjs → dist/cjs}/httpsig.js +0 -0
  60. /package/{cjs → dist/cjs}/id.js +0 -0
  61. /package/{cjs → dist/cjs}/index.js +0 -0
  62. /package/{cjs → dist/cjs}/nocrypto.js +0 -0
  63. /package/{cjs → dist/cjs}/parser.js +0 -0
  64. /package/{cjs → dist/cjs}/send-utils.js +0 -0
  65. /package/{cjs → dist/cjs}/send.js +0 -0
  66. /package/{cjs → dist/cjs}/signer-utils.js +0 -0
  67. /package/{cjs → dist/cjs}/signer.js +0 -0
  68. /package/{cjs → dist/cjs}/structured.js +0 -0
  69. /package/{cjs → dist/cjs}/test.js +0 -0
  70. /package/{cjs → dist/cjs}/utils.js +0 -0
  71. /package/{esm → dist/esm}/bin_to_str.js +0 -0
  72. /package/{esm → dist/esm}/collect-body-keys.js +0 -0
  73. /package/{esm → dist/esm}/commit.js +0 -0
  74. /package/{esm → dist/esm}/encode-array-item.js +0 -0
  75. /package/{esm → dist/esm}/encode-utils.js +0 -0
  76. /package/{esm → dist/esm}/encode.js +0 -0
  77. /package/{esm → dist/esm}/erl_json.js +0 -0
  78. /package/{esm → dist/esm}/erl_str.js +0 -0
  79. /package/{esm → dist/esm}/flat.js +0 -0
  80. /package/{esm → dist/esm}/http-message-signatures/httpbis.js +0 -0
  81. /package/{esm → dist/esm}/http-message-signatures/index.js +0 -0
  82. /package/{esm → dist/esm}/http-message-signatures/structured-header.js +0 -0
  83. /package/{esm → dist/esm}/httpsig.js +0 -0
  84. /package/{esm → dist/esm}/id.js +0 -0
  85. /package/{esm → dist/esm}/index.js +0 -0
  86. /package/{esm → dist/esm}/nocrypto.js +0 -0
  87. /package/{esm → dist/esm}/package.json +0 -0
  88. /package/{esm → dist/esm}/parser.js +0 -0
  89. /package/{esm → dist/esm}/send-utils.js +0 -0
  90. /package/{esm → dist/esm}/send.js +0 -0
  91. /package/{esm → dist/esm}/signer-utils.js +0 -0
  92. /package/{esm → dist/esm}/signer.js +0 -0
  93. /package/{esm → dist/esm}/structured.js +0 -0
  94. /package/{esm → dist/esm}/test.js +0 -0
  95. /package/{esm → dist/esm}/utils.js +0 -0
package/src/id.js ADDED
@@ -0,0 +1,459 @@
1
+ import { hash, hmac } from "fast-sha256"
2
+
3
+ /**
4
+ * Parse structured field dictionary format
5
+ * Handles both complex format: name=(components);params
6
+ * and simple format: name=:value:
7
+ */
8
+ function parseStructuredFieldDictionary(input) {
9
+ // Try complex format first
10
+ const match = input.match(/([^=]+)=\((.*?)\);(.*)$/)
11
+ if (match) {
12
+ const name = match[1]
13
+ const components = match[2].split(" ")
14
+ const params = {}
15
+
16
+ const paramPairs = match[3].split(";").filter(p => p)
17
+ paramPairs.forEach(pair => {
18
+ const [key, value] = pair.split("=")
19
+ if (key && value) {
20
+ params[key] = value.replace(/"/g, "")
21
+ }
22
+ })
23
+
24
+ return { name, components, params }
25
+ }
26
+
27
+ // Try simple format
28
+ const simpleMatch = input.match(/([^=]+)=:([^:]+):/)
29
+ if (simpleMatch) {
30
+ return { name: simpleMatch[1], value: simpleMatch[2] }
31
+ }
32
+
33
+ return null
34
+ }
35
+
36
+ /**
37
+ * Convert base64url string to base64
38
+ */
39
+ function base64urlToBase64(str) {
40
+ return str.replace(/-/g, "+").replace(/_/g, "/")
41
+ }
42
+
43
+ /**
44
+ * Generate commitment ID for RSA-PSS and ECDSA signatures
45
+ * The ID is the SHA256 hash of the raw signature bytes
46
+ *
47
+ * @param {Object} commitment - The commitment object containing signature
48
+ * @returns {string} The commitment ID in base64url format
49
+ */
50
+ function rsaid(commitment) {
51
+ // Extract the base64 signature from structured field format
52
+ // Format: "signature-name=:BASE64_SIGNATURE:"
53
+ const match = commitment.signature.match(/^[^=]+=:([^:]+):/)
54
+ if (!match) {
55
+ throw new Error("Invalid signature format")
56
+ }
57
+
58
+ const signatureBase64 = match[1]
59
+ // Convert base64 to Uint8Array
60
+ const signatureBinary = Uint8Array.from(atob(signatureBase64), c =>
61
+ c.charCodeAt(0)
62
+ )
63
+
64
+ // SHA256 hash of the raw signature
65
+ const hashResult = hash(signatureBinary)
66
+ const id = uint8ArrayToBase64url(hashResult)
67
+
68
+ return id
69
+ }
70
+
71
+ /**
72
+ * Generate HMAC commitment ID for HyperBEAM messages
73
+ * The ID is deterministic based on message content only
74
+ *
75
+ * The Erlang implementation sorts components WITH @ prefix included,
76
+ * then removes @ from derived components in the signature base.
77
+ *
78
+ * @param {Object} message - The message with signature and signature-input
79
+ * @returns {string} The commitment ID in base64url format
80
+ */
81
+ function hmacid(message) {
82
+ // Parse signature-input to get components
83
+ const parsed = parseStructuredFieldDictionary(message["signature-input"])
84
+ if (!parsed || !parsed.components) {
85
+ throw new Error("Failed to parse signature-input")
86
+ }
87
+
88
+ // Sort components AS-IS (with quotes and @ prefix)
89
+ const sortedComponents = [...parsed.components].sort()
90
+
91
+ // Build signature base in sorted order
92
+ const lines = []
93
+
94
+ sortedComponents.forEach(component => {
95
+ const cleanComponent = component.replace(/"/g, "")
96
+ let fieldName = cleanComponent
97
+ let value
98
+
99
+ // For derived components (starting with @), remove @ in the signature base
100
+ if (cleanComponent.startsWith("@")) {
101
+ fieldName = cleanComponent.substring(1)
102
+ value = message[fieldName]
103
+ } else {
104
+ value = message[cleanComponent]
105
+ }
106
+
107
+ if (value === undefined || value === null) {
108
+ value = ""
109
+ } else if (typeof value === "number") {
110
+ value = value.toString()
111
+ }
112
+
113
+ lines.push(`"${fieldName}": ${value}`)
114
+ })
115
+
116
+ // Add signature-params line with sorted components (keeping @ prefix)
117
+ const paramsComponents = sortedComponents.join(" ")
118
+ lines.push(
119
+ `"@signature-params": (${paramsComponents});alg="hmac-sha256";keyid="ao"`
120
+ )
121
+
122
+ const signatureBase = lines.join("\n")
123
+
124
+ // Generate HMAC with key "ao"
125
+ // Convert string to Uint8Array
126
+ const messageBytes = new TextEncoder().encode(signatureBase)
127
+ const keyBytes = new TextEncoder().encode("ao")
128
+
129
+ const hmacResult = hmac(keyBytes, messageBytes)
130
+ return uint8ArrayToBase64url(hmacResult)
131
+ }
132
+
133
+ /**
134
+ * Generate commitment ID based on the algorithm type
135
+ *
136
+ * @param {Object} commitment - The commitment object containing alg, signature, etc.
137
+ * @param {Object} fullMessage - The full message (required for HMAC)
138
+ * @returns {string} The commitment ID in base64url format
139
+ */
140
+ function generateCommitmentId(commitment, fullMessage = null) {
141
+ switch (commitment.alg) {
142
+ case "rsa-pss-sha512":
143
+ case "ecdsa-p256-sha256":
144
+ return rsaid(commitment)
145
+
146
+ case "hmac-sha256":
147
+ if (!fullMessage) {
148
+ throw new Error("HMAC commitment IDs require full message context")
149
+ }
150
+ return hmacid(fullMessage)
151
+
152
+ default:
153
+ throw new Error(`Unsupported algorithm: ${commitment.alg}`)
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Extract all commitment IDs from a HyperBEAM message
159
+ *
160
+ * @param {Object} message - The message with commitments
161
+ * @returns {Object} Map of commitment IDs to their types
162
+ */
163
+ function extractCommitmentIds(message) {
164
+ const ids = {}
165
+
166
+ if (!message.commitments) {
167
+ return ids
168
+ }
169
+
170
+ for (const [id, commitment] of Object.entries(message.commitments)) {
171
+ ids[id] = {
172
+ alg: commitment.alg,
173
+ committer: commitment.committer,
174
+ device: commitment["commitment-device"],
175
+ }
176
+ }
177
+
178
+ return ids
179
+ }
180
+
181
+ /**
182
+ * Verify a commitment ID matches the expected value
183
+ *
184
+ * @param {Object} commitment - The commitment object
185
+ * @param {string} expectedId - The expected commitment ID
186
+ * @param {Object} fullMessage - The full message (required for HMAC)
187
+ * @returns {boolean} True if the ID matches
188
+ */
189
+ function verifyCommitmentId(commitment, expectedId, fullMessage = null) {
190
+ try {
191
+ const calculatedId = generateCommitmentId(commitment, fullMessage)
192
+ return calculatedId === expectedId
193
+ } catch (error) {
194
+ console.error("Error verifying commitment ID:", error)
195
+ return false
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Convert Uint8Array to base64url string
201
+ */
202
+ function uint8ArrayToBase64url(bytes) {
203
+ let binary = ""
204
+ for (let i = 0; i < bytes.length; i++) {
205
+ binary += String.fromCharCode(bytes[i])
206
+ }
207
+ const base64 = btoa(binary)
208
+ return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
209
+ }
210
+
211
+ /**
212
+ * Parse structured field dictionary to extract components
213
+ * Handles format: name=(components);params
214
+ */
215
+ function parseSignatureInput(sigInput) {
216
+ // Extract components from format: name=(components);params
217
+ const match = sigInput.match(/[^=]+=\(([^)]+)\)/)
218
+ if (!match) return []
219
+
220
+ // Split components and clean quotes
221
+ return match[1].split(" ").map(c => c.replace(/"/g, ""))
222
+ }
223
+
224
+ /**
225
+ * Calculate HMAC commitment ID for HyperBEAM messages
226
+ */
227
+ function calculateHmacId(message) {
228
+ if (!message["signature-input"]) {
229
+ throw new Error("HMAC calculation requires signature-input")
230
+ }
231
+
232
+ // Parse components from signature-input
233
+ const components = parseSignatureInput(message["signature-input"])
234
+
235
+ // Sort components AS-IS (with @ prefix)
236
+ const sortedComponents = [...components].sort()
237
+
238
+ // Build signature base in sorted order
239
+ const lines = []
240
+
241
+ for (const component of sortedComponents) {
242
+ let fieldName = component
243
+ let value
244
+
245
+ // For derived components (starting with @), remove @ in the signature base
246
+ if (component.startsWith("@")) {
247
+ fieldName = component.substring(1)
248
+ value = message[fieldName]
249
+ } else {
250
+ value = message[component]
251
+ }
252
+
253
+ if (value === undefined || value === null) {
254
+ value = ""
255
+ } else if (typeof value === "number") {
256
+ value = value.toString()
257
+ }
258
+
259
+ lines.push(`"${fieldName}": ${value}`)
260
+ }
261
+
262
+ // Add signature-params line with sorted components (keeping @ prefix)
263
+ const paramsComponents = sortedComponents.join(" ")
264
+ lines.push(
265
+ `"@signature-params": (${paramsComponents});alg="hmac-sha256";keyid="ao"`
266
+ )
267
+
268
+ const signatureBase = lines.join("\n")
269
+
270
+ // Generate HMAC with key "ao"
271
+ const messageBytes = new TextEncoder().encode(signatureBase)
272
+ const keyBytes = new TextEncoder().encode("ao")
273
+
274
+ const hmacResult = hmac(keyBytes, messageBytes)
275
+ return uint8ArrayToBase64url(hmacResult)
276
+ }
277
+
278
+ /**
279
+ * Calculate unsigned message ID following the exact Erlang flow
280
+ */
281
+ function calculateUnsignedId(message) {
282
+ // Derived components from Erlang ?DERIVED_COMPONENTS
283
+ const DERIVED_COMPONENTS = [
284
+ "method",
285
+ "target-uri",
286
+ "authority",
287
+ "scheme",
288
+ "request-target",
289
+ "path",
290
+ "query",
291
+ "query-param",
292
+ "status",
293
+ ]
294
+
295
+ // Convert message for httpsig format
296
+ const httpsigMsg = {}
297
+ for (const [key, value] of Object.entries(message)) {
298
+ httpsigMsg[key.toLowerCase()] = value
299
+ }
300
+
301
+ // Get keys and add @ to derived components
302
+ const keys = Object.keys(httpsigMsg)
303
+ const componentsWithPrefix = keys
304
+ .map(key => {
305
+ // Check if this is a derived component
306
+ if (DERIVED_COMPONENTS.includes(key.replace(/_/g, "-"))) {
307
+ return "@" + key
308
+ }
309
+ return key
310
+ })
311
+ .sort() // Sort AFTER adding @ prefix
312
+
313
+ // Build signature base - use the components in order
314
+ const lines = []
315
+ for (const component of componentsWithPrefix) {
316
+ const key = component.replace("@", "")
317
+ const value = httpsigMsg[key]
318
+ const valueStr = typeof value === "string" ? value : String(value)
319
+ lines.push(`"${key}": ${valueStr}`)
320
+ }
321
+
322
+ // Add signature-params line with the @ prefixes
323
+ const componentsList = componentsWithPrefix.map(k => `"${k}"`).join(" ")
324
+ lines.push(
325
+ `"@signature-params": (${componentsList});alg="hmac-sha256";keyid="ao"`
326
+ )
327
+
328
+ const signatureBase = lines.join("\n")
329
+
330
+ // HMAC with key "ao"
331
+ const messageBytes = new TextEncoder().encode(signatureBase)
332
+ const keyBytes = new TextEncoder().encode("ao")
333
+
334
+ const hmacResult = hmac(keyBytes, messageBytes)
335
+ return uint8ArrayToBase64url(hmacResult)
336
+ }
337
+
338
+ function id(message) {
339
+ // Get commitment IDs
340
+ const commitmentIds = Object.keys(message.commitments || {})
341
+
342
+ if (commitmentIds.length === 0) {
343
+ // No commitments - calculate unsigned ID using HMAC
344
+ return calculateUnsignedId(message)
345
+ } else if (commitmentIds.length === 1) {
346
+ // Single commitment - the ID is just the commitment ID
347
+ return commitmentIds[0]
348
+ } else {
349
+ // Multiple commitments - sort, join with ", ", and hash
350
+ const sortedIds = commitmentIds.sort()
351
+ const idsLine = sortedIds.join(", ")
352
+
353
+ // Calculate SHA-256 hash using fast-sha256
354
+ const encoder = new TextEncoder()
355
+ const data = encoder.encode(idsLine)
356
+ const hashArray = hash(data)
357
+
358
+ // Convert to base64url
359
+ return uint8ArrayToBase64url(hashArray)
360
+ }
361
+ }
362
+ // Export all functions
363
+ export {
364
+ id,
365
+ generateCommitmentId,
366
+ rsaid,
367
+ hmacid,
368
+ extractCommitmentIds,
369
+ verifyCommitmentId,
370
+ parseStructuredFieldDictionary,
371
+ }
372
+
373
+ /**
374
+ * Calculate the next base from a hashpath
375
+ * A hashpath has the format: base/request
376
+ * The next base is calculated as: sha256(base + request)
377
+ *
378
+ * @param {string} hashpath - The current hashpath in format "base/request"
379
+ * @returns {string} The next base in base64url format
380
+ */
381
+ function base(hashpath) {
382
+ // Split the hashpath into base and request
383
+ const parts = hashpath.split("/")
384
+ if (parts.length !== 2) {
385
+ throw new Error("Invalid hashpath format. Expected 'base/request'")
386
+ }
387
+
388
+ const [base, request] = parts
389
+
390
+ // Convert base64url to native binary (Uint8Array)
391
+ const baseBinary = base64urlToUint8Array(base)
392
+ const requestBinary = base64urlToUint8Array(request)
393
+
394
+ // Concatenate base and request
395
+ const combined = new Uint8Array(baseBinary.length + requestBinary.length)
396
+ combined.set(baseBinary, 0)
397
+ combined.set(requestBinary, baseBinary.length)
398
+
399
+ // Calculate SHA256 of the combined data
400
+ const nextBaseHash = hash(combined)
401
+
402
+ // Convert to base64url
403
+ return uint8ArrayToBase64url(nextBaseHash)
404
+ }
405
+
406
+ /**
407
+ * Calculate the next hashpath given the current hashpath and a new message
408
+ *
409
+ * @param {string} currentHashpath - The current hashpath (or null for first operation)
410
+ * @param {Object} newMessage - The new message/request
411
+ * @returns {string} The next hashpath in format "nextBase/newMessageId"
412
+ */
413
+ function hashpath(currentHashpath, newMessage) {
414
+ // Calculate the ID of the new message
415
+ const newMessageId = id(newMessage)
416
+
417
+ if (!currentHashpath) {
418
+ // First operation: the hashpath is just the message ID
419
+ // In the Erlang code, the first hashpath is "baseId/requestId"
420
+ // where baseId is the ID of the initial message
421
+ throw new Error(
422
+ "For first operation, provide the base message ID as currentHashpath"
423
+ )
424
+ }
425
+
426
+ // Check if this is the first operation (currentHashpath is just an ID, not a path)
427
+ if (!currentHashpath.includes("/")) {
428
+ // First operation: currentHashpath is the base message ID
429
+ return `${currentHashpath}/${newMessageId}`
430
+ }
431
+
432
+ // Subsequent operations: calculate the next base from current hashpath
433
+ const nextBase = base(currentHashpath)
434
+
435
+ // Return the new hashpath
436
+ return `${nextBase}/${newMessageId}`
437
+ }
438
+
439
+ /**
440
+ * Helper function to convert base64url string to Uint8Array
441
+ */
442
+ function base64urlToUint8Array(base64url) {
443
+ // Convert base64url to base64
444
+ const base64 = base64urlToBase64(base64url)
445
+
446
+ // Decode base64 to binary string
447
+ const binaryString = atob(base64)
448
+
449
+ // Convert binary string to Uint8Array
450
+ const bytes = new Uint8Array(binaryString.length)
451
+ for (let i = 0; i < binaryString.length; i++) {
452
+ bytes[i] = binaryString.charCodeAt(i)
453
+ }
454
+
455
+ return bytes
456
+ }
457
+
458
+ // Export the new functions
459
+ export { base, hashpath }
package/src/index.js ADDED
@@ -0,0 +1,13 @@
1
+ export { id, base, hashpath, rsaid, hmacid } from "./id.js"
2
+ export { toAddr } from "./utils.js"
3
+ export { sign, signer, createSigner } from "./signer.js"
4
+ export { send } from "./send.js"
5
+ export { commit } from "./commit.js"
6
+ export { result } from "./send-utils.js"
7
+ export { extractPubKey, decodeSigInput } from "./parser.js"
8
+ export { verify } from "./signer-utils.js"
9
+ export { normalize, erl_json_to, erl_json_from } from "./erl_json.js"
10
+ export { erl_str_from, erl_str_to } from "./erl_str.js"
11
+ export { structured_from, structured_to } from "./structured.js"
12
+ export { httpsig_from, httpsig_to } from "./httpsig.js"
13
+ export { flat_from, flat_to } from "./flat.js"
@@ -0,0 +1,4 @@
1
+ export { id, hashpath, rsaid, hmacid } from "./id.js"
2
+ export { extractPubKey } from "./parser.js"
3
+ export { structured_to } from "./structured.js"
4
+ export { httpsig_from } from "./httpsig.js"
package/src/parser.js ADDED
@@ -0,0 +1,171 @@
1
+ import base64url from "base64url"
2
+
3
+ /**
4
+ * Decode signature-input header to extract all components
5
+ * Handles format: signature-name=(components);params
6
+ *
7
+ * @param {string} signatureInput - The signature-input header value
8
+ * @param {string} [signatureName] - Optional specific signature name to decode
9
+ * @returns {Object} Decoded signature input with components and parameters
10
+ */
11
+ export function decodeSigInput(signatureInput, signatureName = null) {
12
+ if (!signatureInput) {
13
+ return null
14
+ }
15
+
16
+ try {
17
+ // If signature name is provided, extract just that section
18
+ let inputToDecode = signatureInput
19
+ if (signatureName) {
20
+ // Find the section for this specific signature
21
+ const startIndex = signatureInput.indexOf(signatureName)
22
+ if (startIndex === -1) {
23
+ return null
24
+ }
25
+
26
+ // Extract from signature name to the next signature (if any) or end
27
+ const nextSigMatch = signatureInput
28
+ .substring(startIndex + signatureName.length)
29
+ .match(/,\s*[a-zA-Z0-9_-]+=/)
30
+ const endIndex = nextSigMatch
31
+ ? startIndex + signatureName.length + nextSigMatch.index
32
+ : signatureInput.length
33
+
34
+ inputToDecode = signatureInput.substring(startIndex, endIndex).trim()
35
+ }
36
+
37
+ // Parse each signature entry
38
+ const signatures = {}
39
+
40
+ // Split by signature entries (handle multiple signatures)
41
+ const entries = inputToDecode.split(/,(?=\s*[a-zA-Z0-9_-]+=)/)
42
+
43
+ for (const entry of entries) {
44
+ const trimmedEntry = entry.trim()
45
+
46
+ // Match signature-name=(components);params format
47
+ const match = trimmedEntry.match(/^([a-zA-Z0-9_-]+)=\(([^)]*)\)(.*)$/)
48
+ if (!match) {
49
+ continue
50
+ }
51
+
52
+ const sigName = match[1]
53
+ const componentsStr = match[2]
54
+ const paramsStr = match[3]
55
+
56
+ // Parse components (space-separated, may be quoted)
57
+ const components = []
58
+ const componentRegex = /"[^"]+"|[^\s]+/g
59
+ let componentMatch
60
+ while ((componentMatch = componentRegex.exec(componentsStr)) !== null) {
61
+ components.push(componentMatch[0].replace(/"/g, ""))
62
+ }
63
+
64
+ // Parse parameters (semicolon-separated key="value" pairs)
65
+ const params = {}
66
+ if (paramsStr) {
67
+ const paramPairs = paramsStr.split(";").filter(p => p.trim())
68
+ for (const pair of paramPairs) {
69
+ const [key, ...valueParts] = pair.split("=")
70
+ if (key && valueParts.length > 0) {
71
+ const value = valueParts.join("=").replace(/^"|"$/g, "")
72
+ params[key.trim()] = value
73
+ }
74
+ }
75
+ }
76
+
77
+ signatures[sigName] = {
78
+ components,
79
+ params,
80
+ raw: trimmedEntry,
81
+ }
82
+ }
83
+
84
+ // If specific signature was requested, return just that one
85
+ if (signatureName && signatures[signatureName]) {
86
+ return signatures[signatureName]
87
+ }
88
+
89
+ // Return all signatures or the first one if no specific name was given
90
+ return signatureName ? null : signatures
91
+ } catch (error) {
92
+ console.error("Error decoding signature-input:", error)
93
+ return null
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Extract signature name from headers
99
+ * @param {Object} headers - Request headers
100
+ * @returns {string|null} Signature name or null
101
+ */
102
+ function extractSignatureName(headers) {
103
+ const signatureHeader = headers["signature"] || headers["Signature"]
104
+ if (!signatureHeader) return null
105
+
106
+ // Extract signature name (e.g., "http-sig-xxxxxxxx")
107
+ // Handle both "name:" and "name=" formats
108
+ const match = signatureHeader.match(/^([^:=]+)[:=]/)
109
+ return match ? match[1] : null
110
+ }
111
+
112
+ /**
113
+ * Extract public key from signature-input header
114
+ * @param {Object} headers - Request headers
115
+ * @param {string} [signatureName] - Optional signature name to look for
116
+ * @returns {Buffer|null} Public key buffer or null
117
+ */
118
+ export function extractPubKey(headers, signatureName) {
119
+ const signatureInput =
120
+ headers["signature-input"] || headers["Signature-Input"]
121
+ if (!signatureInput) return null
122
+
123
+ // Use the decoder to properly parse the signature-input
124
+ const decoded = decodeSigInput(signatureInput, signatureName)
125
+
126
+ if (!decoded) return null
127
+
128
+ // If we decoded a specific signature, use its keyid
129
+ // When no specific signatureName, prefer RSA signature over HMAC
130
+ // (HMAC keyid is "constant:ao" which is not a real public key)
131
+ let keyid = null
132
+ if (signatureName && decoded.params) {
133
+ keyid = decoded.params.keyid
134
+ } else {
135
+ const entries = Object.values(decoded)
136
+ const rsaEntry = entries.find(e => e.params?.alg?.startsWith("rsa-"))
137
+ keyid = rsaEntry?.params?.keyid || entries[0]?.params?.keyid
138
+ }
139
+
140
+ if (!keyid) return null
141
+
142
+ try {
143
+ // Strip scheme prefix if present (e.g., "publickey:base64data" -> "base64data")
144
+ let keyidToDecode = keyid
145
+ if (keyid.includes(":")) {
146
+ const colonIndex = keyid.indexOf(":")
147
+ keyidToDecode = keyid.substring(colonIndex + 1)
148
+ }
149
+ // Handle both base64url and standard base64 encoding
150
+ // Standard base64 uses +/ while base64url uses -_
151
+ if (keyidToDecode.includes("+") || keyidToDecode.includes("/")) {
152
+ // Standard base64 - convert to buffer directly
153
+ return Buffer.from(keyidToDecode, "base64")
154
+ }
155
+ return base64url.toBuffer(keyidToDecode)
156
+ } catch (error) {
157
+ return null
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Extract public key from a signed message
163
+ * This is a convenience wrapper that extracts the signature name first
164
+ *
165
+ * @param {Object} signedMessage - The signed message with headers
166
+ * @returns {Buffer|null} The public key buffer or null
167
+ */
168
+ export function extractPublicKeyFromMessage(signedMessage) {
169
+ const signatureName = extractSignatureName(signedMessage.headers)
170
+ return extractPubKey(signedMessage.headers, signatureName)
171
+ }