hbsig 0.2.8 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/esm/parser.js CHANGED
@@ -26,7 +26,7 @@ export function decodeSigInput(signatureInput, signatureName = null) {
26
26
  // Extract from signature name to the next signature (if any) or end
27
27
  const nextSigMatch = signatureInput
28
28
  .substring(startIndex + signatureName.length)
29
- .match(/,\s*[a-zA-Z0-9-]+=/)
29
+ .match(/,\s*[a-zA-Z0-9_-]+=/)
30
30
  const endIndex = nextSigMatch
31
31
  ? startIndex + signatureName.length + nextSigMatch.index
32
32
  : signatureInput.length
@@ -38,13 +38,13 @@ export function decodeSigInput(signatureInput, signatureName = null) {
38
38
  const signatures = {}
39
39
 
40
40
  // Split by signature entries (handle multiple signatures)
41
- const entries = inputToDecode.split(/,(?=\s*[a-zA-Z0-9-]+=)/)
41
+ const entries = inputToDecode.split(/,(?=\s*[a-zA-Z0-9_-]+=)/)
42
42
 
43
43
  for (const entry of entries) {
44
44
  const trimmedEntry = entry.trim()
45
45
 
46
46
  // Match signature-name=(components);params format
47
- const match = trimmedEntry.match(/^([a-zA-Z0-9-]+)=\(([^)]*)\)(.*)$/)
47
+ const match = trimmedEntry.match(/^([a-zA-Z0-9_-]+)=\(([^)]*)\)(.*)$/)
48
48
  if (!match) {
49
49
  continue
50
50
  }
@@ -126,15 +126,33 @@ export function extractPubKey(headers, signatureName) {
126
126
  if (!decoded) return null
127
127
 
128
128
  // If we decoded a specific signature, use its keyid
129
- const keyid =
130
- signatureName && decoded.params
131
- ? decoded.params.keyid
132
- : Object.values(decoded)[0]?.params?.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
+ }
133
139
 
134
140
  if (!keyid) return null
135
141
 
136
142
  try {
137
- return base64url.toBuffer(keyid)
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)
138
156
  } catch (error) {
139
157
  return null
140
158
  }
package/esm/send.js CHANGED
@@ -41,7 +41,6 @@ export async function send(signedMsg, fetchImpl = fetch) {
41
41
  ) {
42
42
  fetchOptions.body = signedMsg.body
43
43
  }
44
-
45
44
  const response = await fetchImpl(signedMsg.url, fetchOptions)
46
45
  if (response.status >= 400) {
47
46
  throw new Error(`${response.status}: ${await response.text()}`)
@@ -54,7 +53,9 @@ export const httpSigName = address => {
54
53
  const hexString = [...decoded.subarray(1, 9)]
55
54
  .map(byte => byte.toString(16).padStart(2, "0"))
56
55
  .join("")
57
- return `http-sig-${hexString}`
56
+ // Use 'comm-' prefix to match HyperBEAM's siginfo_to_commitments pattern
57
+ // (it strips <<"comm-", Rest/binary>> before parsing structured fields)
58
+ return `comm-${hexString}`
58
59
  }
59
60
 
60
61
  const toView = value => {
@@ -80,11 +81,15 @@ export const toHttpSigner = signer => {
80
81
  const { publicKey, alg = "rsa-pss-sha512" } = injected
81
82
 
82
83
  const publicKeyBuffer = toView(publicKey)
84
+ // Use standard base64 encoding for keyid to be compatible with HyperBEAM's
85
+ // base64:decode in dev_codec_httpsig_keyid:apply_scheme
86
+ // Include "publickey:" prefix so HyperBEAM knows the key scheme
87
+ const keyidBase64 = `publickey:${publicKeyBuffer.toString("base64")}`
83
88
 
84
89
  const signingParameters = createSigningParameters({
85
90
  params,
86
91
  paramValues: {
87
- keyid: base64url.encode(publicKeyBuffer),
92
+ keyid: keyidBase64,
88
93
  alg,
89
94
  },
90
95
  })
@@ -282,11 +282,15 @@ export const toHttpSigner = signer => {
282
282
  const { publicKey, alg = "rsa-pss-sha512" } = injected
283
283
 
284
284
  const publicKeyBuffer = toView(publicKey)
285
+ // Use standard base64 encoding for keyid to be compatible with HyperBEAM's
286
+ // base64:decode in dev_codec_httpsig_keyid:apply_scheme
287
+ // Include "publickey:" prefix so HyperBEAM knows the key scheme
288
+ const keyidBase64 = `publickey:${publicKeyBuffer.toString("base64")}`
285
289
 
286
290
  const signingParameters = createSigningParameters({
287
291
  params,
288
292
  paramValues: {
289
- keyid: base64url.encode(publicKeyBuffer),
293
+ keyid: keyidBase64,
290
294
  alg,
291
295
  },
292
296
  })
package/esm/signer.js CHANGED
@@ -96,173 +96,22 @@ const hasBinaryData = obj => {
96
96
  return false
97
97
  }
98
98
 
99
- // Helper to check if value is a simple array that should use structured fields
100
- const isSimpleArray = value => {
101
- if (!Array.isArray(value)) return false
102
-
103
- return value.every(item => {
104
- // Simple types that can be in structured field lists
105
- if (typeof item === "string") return true
106
- if (typeof item === "number") return true
107
- if (typeof item === "boolean") return true
108
- if (isBytes(item)) return true
109
-
110
- // Complex types cannot be in structured field lists
111
- if (item && typeof item === "object") return false
112
-
113
- return true
114
- })
115
- }
116
-
117
- // Helper to encode array as structured field list
118
- const encodeAsStructuredFieldList = arr => {
119
- return arr
120
- .map(item => {
121
- if (typeof item === "string") {
122
- // String values are quoted
123
- return `"${item.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`
124
- } else if (typeof item === "number") {
125
- // Numbers are bare
126
- return String(item)
127
- } else if (typeof item === "boolean") {
128
- // Booleans use ?0 or ?1
129
- return item ? "?1" : "?0"
130
- } else if (isBytes(item)) {
131
- // Binary data as byte sequences
132
- const buffer = Buffer.isBuffer(item) ? item : Buffer.from(item)
133
- return `:${buffer.toString("base64")}:`
134
- } else {
135
- // Fallback
136
- return `"${String(item)}"`
137
- }
138
- })
139
- .join(", ")
140
- }
141
-
142
- const smartSign = async (obj, path) => {
143
- try {
144
- // Filter out undefined values
145
- const filtered = filterUndefined(obj)
146
-
147
- // Check if we can encode everything as headers (no multipart needed)
148
- let canUseSimpleEncoding = true
149
- let hasBodyField = false
150
-
151
- for (const [key, value] of Object.entries(filtered)) {
152
- if (key === "path") continue
153
-
154
- // Check if this is the "body" field
155
- if (key === "body" || key === "data") {
156
- hasBodyField = true
157
- // Only use multipart if body/data is actually binary
158
- if (isBytes(value)) {
159
- canUseSimpleEncoding = false
160
- break
161
- }
162
- }
163
-
164
- // Complex nested objects need multipart
165
- if (
166
- value &&
167
- typeof value === "object" &&
168
- !Array.isArray(value) &&
169
- !isBytes(value)
170
- ) {
171
- if (Object.keys(value).length > 0) {
172
- canUseSimpleEncoding = false
173
- break
174
- }
175
- }
176
-
177
- // Arrays with complex items need multipart
178
- if (Array.isArray(value) && !isSimpleArray(value)) {
179
- canUseSimpleEncoding = false
180
- break
181
- }
182
- }
183
-
184
- if (canUseSimpleEncoding) {
185
- // Build a simple message that won't trigger multipart
186
- const message = {}
187
- if (path) message.path = path
188
-
189
- const types = []
190
-
191
- for (const [key, value] of Object.entries(filtered)) {
192
- if (key === "path") continue
193
-
194
- if (value === "") {
195
- types.push(`${key}="empty-binary"`)
196
- } else if (Array.isArray(value) && value.length === 0) {
197
- types.push(`${key}="empty-list"`)
198
- } else if (
199
- value &&
200
- typeof value === "object" &&
201
- Object.keys(value).length === 0
202
- ) {
203
- types.push(`${key}="empty-message"`)
204
- } else if (isSimpleArray(value)) {
205
- types.push(`${key}="list"`)
206
- message[key] = encodeAsStructuredFieldList(value)
207
- } else if (typeof value === "number") {
208
- types.push(
209
- `${key}="${Number.isInteger(value) ? "integer" : "float"}"`
210
- )
211
- message[key] = String(value)
212
- } else if (typeof value === "boolean") {
213
- types.push(`${key}="atom"`)
214
- message[key] = String(value)
215
- } else if (value === null || value === undefined) {
216
- types.push(`${key}="atom"`)
217
- message[key] = String(value)
218
- } else if (typeof value === "string") {
219
- message[key] = value
220
- }
221
- }
222
-
223
- if (types.length > 0) {
224
- message["ao-types"] = types.join(", ")
225
- }
226
-
227
- return httpsig_to(message)
228
- }
229
-
230
- // For complex structures that need multipart, use enc()
231
- const normalized = normalize({
232
- ...filtered,
233
- ...(path && { path }),
234
- })
235
- const result = await enc(normalized)
236
-
237
- // enc() returns { headers: {...}, body: ... }
238
- // We need to flatten this for httpsig_to
239
- const flattened = {
240
- ...result.headers,
241
- body: result.body,
242
- ...(path && { path }),
243
- }
244
-
245
- // httpsig_to expects the structured format
246
- const encoded = httpsig_to(flattened)
247
-
248
- return encoded
249
- } catch (error) {
250
- console.error("Encoding failed:", error)
251
-
252
- // Fallback: create a simple structure
253
- const result = {}
254
- if (path) result.path = path
255
-
256
- for (const [key, value] of Object.entries(obj)) {
257
- if (key === "path") continue
258
-
259
- if (!isBytes(value) && value !== undefined) {
260
- result[key] = value
261
- }
99
+ // Helper to build ao-types string from an object
100
+ const buildAoTypes = (obj) => {
101
+ const types = []
102
+ for (const [key, value] of Object.entries(obj)) {
103
+ if (typeof value === "number") {
104
+ types.push(`${key}="${Number.isInteger(value) ? "integer" : "float"}"`)
105
+ } else if (typeof value === "boolean") {
106
+ types.push(`${key}="atom"`)
107
+ } else if (value === null) {
108
+ types.push(`${key}="atom"`)
109
+ } else if (typeof value === "symbol") {
110
+ // Symbols are Erlang atoms
111
+ types.push(`${key}="atom"`)
262
112
  }
263
-
264
- return result
265
113
  }
114
+ return types.length > 0 ? types.join(", ") : null
266
115
  }
267
116
 
268
117
  // Internal encode function that uses the original impl as much as possible
@@ -272,22 +121,30 @@ const encode = async (obj, path) => {
272
121
 
273
122
  // If object contains binary data, use enc() directly
274
123
  if (hasBinaryData(filtered)) {
275
- // For binary data, use enc() which handles multipart
276
124
  return await enc(filtered)
277
125
  }
278
126
 
279
- // Otherwise use the standard pipeline
280
- let fields = { ...filtered }
281
127
  // Only add path if explicitly provided
128
+ let fields = { ...filtered }
282
129
  if (path) fields.path = path
283
130
 
131
+ // Build ao-types annotation for typed values (integers, booleans, etc.)
132
+ // This tells HyperBEAM how to convert values during verification
133
+ // Merge with any existing ao-types (e.g., list annotations from hb.js)
134
+ const aoTypes = buildAoTypes(filtered)
135
+ if (aoTypes) {
136
+ const existing = fields["ao-types"]
137
+ fields["ao-types"] = existing ? existing + ", " + aoTypes : aoTypes
138
+ }
139
+
284
140
  // Try the standard encoding pipeline
285
141
  const encoded = httpsig_to(normalize(structured_from(normalize(fields))))
286
142
 
287
143
  // Check if the encoded result is valid for HTTP headers
288
144
  if (!isValid(encoded)) {
289
145
  // If invalid, fall back to enc()
290
- return await enc(filtered)
146
+ const encResult = await enc(filtered)
147
+ return encResult
291
148
  }
292
149
 
293
150
  // For non-binary data, return in the same format as enc()
@@ -346,8 +203,14 @@ async function _sign({
346
203
  let url_path = typeof signPath === "string" ? signPath : path
347
204
  const _url = joinUrl({ url, path: url_path })
348
205
 
349
- // Only add path header if path is provided
350
- if (path) headersObj["path"] = path
206
+ // Only add path header if it's a data field (doesn't start with "/").
207
+ // URL paths (like "/relay/process") should NOT be added to headers.
208
+ // Data fields named "path" (e.g., "credit-notice") should be signed.
209
+ const isDataFieldPath = path && typeof path === "string" && !path.startsWith("/")
210
+ if (isDataFieldPath && !headersObj["path"]) headersObj["path"] = path
211
+
212
+ // Add accept-bundle header to request inline data instead of links
213
+ headersObj["accept-bundle"] = "true"
351
214
 
352
215
  if (body && !headersObj["content-length"]) {
353
216
  const bodySize = body.size || body.byteLength || 0
@@ -366,15 +229,27 @@ async function _sign({
366
229
  .map(k => k.trim())
367
230
  : []
368
231
 
232
+ // Exclude metadata fields that get consumed/stripped during JSON codec parsing:
233
+ // - ao-types: used for type conversion, then removed by structured codec
234
+ // - accept-bundle: request metadata for inlining nested data
235
+ // - content-digest: only exclude when no body; when body exists, sign it so
236
+ // HyperBEAM can map content-digest → body → ao-body-key field in committed list
237
+ // Note: "path" as a data field (e.g., path: "credit-notice") should be signed.
238
+ // The @path derived component (HTTP request URL) is handled separately.
239
+ const metadataFields = ["body-keys", "ao-types", "accept-bundle", "content-length"]
240
+ if (!body) {
241
+ metadataFields.push("content-digest")
242
+ }
369
243
  let isPath = false
370
244
  const signingFields = Object.keys(lowercaseHeaders).filter(key => {
371
245
  if (key === "path") isPath = true
372
- return key !== "body-keys" && key !== "path" && !bodyKeys.includes(key)
246
+ return !metadataFields.includes(key) && !bodyKeys.includes(key)
373
247
  })
374
248
 
375
249
  // Only add @path if signPath is enabled AND path header exists
376
250
  if (signPath !== false && isPath) signingFields.push("@path")
377
251
 
252
+
378
253
  const signedRequest = await toHttpSigner(signer)({
379
254
  request: { url: _url, method, headers: lowercaseHeaders },
380
255
  fields: signingFields,
@@ -405,11 +280,28 @@ export function signer(config) {
405
280
  fields,
406
281
  { encoded: _encoded = false, path: signPath = true } = {}
407
282
  ) => {
408
- const { path = "/relay/process", method = "POST", ...aoFields } = fields
283
+ const { method = "POST", ...restFields } = fields
284
+
285
+ // Distinguish URL paths from data fields:
286
+ // - URL paths start with "/" (e.g., "/relay/process")
287
+ // - Data fields don't (e.g., "credit-notice" for P4 ledger actions)
288
+ const fieldsPath = restFields.path
289
+ const isUrlPath = typeof fieldsPath === "string" && fieldsPath.startsWith("/")
290
+ const path = isUrlPath ? fieldsPath : "/relay/process"
291
+
292
+ // Keep path in data fields if it's not a URL path
293
+ let aoFields
294
+ if (isUrlPath) {
295
+ const { path: _, ...rest } = restFields
296
+ aoFields = rest
297
+ } else {
298
+ aoFields = restFields // path stays as data field
299
+ }
300
+
409
301
  const filteredFields = filterUndefined(aoFields)
410
302
  const encoded = _encoded
411
303
  ? filteredFields
412
- : await encode(filteredFields, path)
304
+ : await encode(filteredFields, null)
413
305
  return await _sign({ path, signPath, method, encoded, signer, url })
414
306
  }
415
307
  }