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/cjs/commit.js +263 -43
- package/cjs/encode-utils.js +10 -4
- package/cjs/encode.js +66 -19
- package/cjs/erl_json.js +12 -3
- package/cjs/erl_str.js +5 -0
- package/cjs/flat.js +70 -13
- package/cjs/httpsig.js +159 -173
- package/cjs/parser.js +30 -6
- package/cjs/send.js +11 -7
- package/cjs/signer-utils.js +14 -12
- package/cjs/signer.js +140 -281
- package/cjs/structured.js +140 -146
- package/cjs/test.js +0 -15
- package/esm/commit.js +174 -19
- package/esm/encode-utils.js +10 -4
- package/esm/encode.js +52 -15
- package/esm/erl_json.js +4 -1
- package/esm/erl_str.js +5 -0
- package/esm/flat.js +61 -7
- package/esm/httpsig.js +118 -113
- package/esm/parser.js +26 -8
- package/esm/send.js +8 -3
- package/esm/signer-utils.js +5 -1
- package/esm/signer.js +66 -174
- package/esm/structured.js +97 -98
- package/esm/test.js +2 -6
- package/package.json +2 -2
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-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
92
|
+
keyid: keyidBase64,
|
|
88
93
|
alg,
|
|
89
94
|
},
|
|
90
95
|
})
|
package/esm/signer-utils.js
CHANGED
|
@@ -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:
|
|
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
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (typeof
|
|
106
|
-
|
|
107
|
-
if (
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
|
350
|
-
|
|
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
|
|
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 {
|
|
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,
|
|
304
|
+
: await encode(filteredFields, null)
|
|
413
305
|
return await _sign({ path, signPath, method, encoded, signer, url })
|
|
414
306
|
}
|
|
415
307
|
}
|