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.
- package/.babelrc-cjs +5 -0
- package/.babelrc-esm +5 -0
- package/README.md +1 -0
- package/dist/package.json +39 -0
- package/make.js +36 -0
- package/package.json +16 -17
- package/src/bin_to_str.js +46 -0
- package/src/collect-body-keys.js +436 -0
- package/src/commit.js +219 -0
- package/src/encode-array-item.js +112 -0
- package/src/encode-utils.js +191 -0
- package/src/encode.js +1256 -0
- package/src/erl_json.js +292 -0
- package/src/erl_str.js +1144 -0
- package/src/flat.js +250 -0
- package/src/http-message-signatures/httpbis.js +438 -0
- package/src/http-message-signatures/index.js +4 -0
- package/src/http-message-signatures/structured-header.js +105 -0
- package/src/httpsig.js +866 -0
- package/src/id.js +459 -0
- package/src/index.js +13 -0
- package/src/nocrypto.js +4 -0
- package/src/parser.js +171 -0
- package/src/send-utils.js +1132 -0
- package/src/send.js +142 -0
- package/src/signer-utils.js +375 -0
- package/src/signer.js +312 -0
- package/src/structured.js +496 -0
- package/src/test.js +2 -0
- package/src/utils.js +29 -0
- package/test/commit.test.js +41 -0
- package/test/erl_json.test.js +8 -0
- package/test/flat.test.js +27 -0
- package/test/httpsig.test.js +31 -0
- package/test/id.test.js +114 -0
- package/test/lib/all_cases.js +408 -0
- package/test/lib/cases.js +408 -0
- package/test/lib/erl_json_cases.js +161 -0
- package/test/lib/flat_cases.js +189 -0
- package/test/lib/gen.js +528 -0
- package/test/lib/httpsig_cases.js +313 -0
- package/test/lib/structured_cases.js +222 -0
- package/test/lib/test-utils.js +399 -0
- package/test/signer.test.js +48 -0
- package/test/structured.test.js +35 -0
- package/bin/install-deps +0 -0
- /package/{cjs → dist/cjs}/bin_to_str.js +0 -0
- /package/{cjs → dist/cjs}/collect-body-keys.js +0 -0
- /package/{cjs → dist/cjs}/commit.js +0 -0
- /package/{cjs → dist/cjs}/encode-array-item.js +0 -0
- /package/{cjs → dist/cjs}/encode-utils.js +0 -0
- /package/{cjs → dist/cjs}/encode.js +0 -0
- /package/{cjs → dist/cjs}/erl_json.js +0 -0
- /package/{cjs → dist/cjs}/erl_str.js +0 -0
- /package/{cjs → dist/cjs}/flat.js +0 -0
- /package/{cjs → dist/cjs}/http-message-signatures/httpbis.js +0 -0
- /package/{cjs → dist/cjs}/http-message-signatures/index.js +0 -0
- /package/{cjs → dist/cjs}/http-message-signatures/structured-header.js +0 -0
- /package/{cjs → dist/cjs}/httpsig.js +0 -0
- /package/{cjs → dist/cjs}/id.js +0 -0
- /package/{cjs → dist/cjs}/index.js +0 -0
- /package/{cjs → dist/cjs}/nocrypto.js +0 -0
- /package/{cjs → dist/cjs}/parser.js +0 -0
- /package/{cjs → dist/cjs}/send-utils.js +0 -0
- /package/{cjs → dist/cjs}/send.js +0 -0
- /package/{cjs → dist/cjs}/signer-utils.js +0 -0
- /package/{cjs → dist/cjs}/signer.js +0 -0
- /package/{cjs → dist/cjs}/structured.js +0 -0
- /package/{cjs → dist/cjs}/test.js +0 -0
- /package/{cjs → dist/cjs}/utils.js +0 -0
- /package/{esm → dist/esm}/bin_to_str.js +0 -0
- /package/{esm → dist/esm}/collect-body-keys.js +0 -0
- /package/{esm → dist/esm}/commit.js +0 -0
- /package/{esm → dist/esm}/encode-array-item.js +0 -0
- /package/{esm → dist/esm}/encode-utils.js +0 -0
- /package/{esm → dist/esm}/encode.js +0 -0
- /package/{esm → dist/esm}/erl_json.js +0 -0
- /package/{esm → dist/esm}/erl_str.js +0 -0
- /package/{esm → dist/esm}/flat.js +0 -0
- /package/{esm → dist/esm}/http-message-signatures/httpbis.js +0 -0
- /package/{esm → dist/esm}/http-message-signatures/index.js +0 -0
- /package/{esm → dist/esm}/http-message-signatures/structured-header.js +0 -0
- /package/{esm → dist/esm}/httpsig.js +0 -0
- /package/{esm → dist/esm}/id.js +0 -0
- /package/{esm → dist/esm}/index.js +0 -0
- /package/{esm → dist/esm}/nocrypto.js +0 -0
- /package/{esm → dist/esm}/package.json +0 -0
- /package/{esm → dist/esm}/parser.js +0 -0
- /package/{esm → dist/esm}/send-utils.js +0 -0
- /package/{esm → dist/esm}/send.js +0 -0
- /package/{esm → dist/esm}/signer-utils.js +0 -0
- /package/{esm → dist/esm}/signer.js +0 -0
- /package/{esm → dist/esm}/structured.js +0 -0
- /package/{esm → dist/esm}/test.js +0 -0
- /package/{esm → dist/esm}/utils.js +0 -0
package/src/signer.js
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { httpsig_from, httpsig_to } from "./httpsig.js"
|
|
2
|
+
import { structured_from, structured_to } from "./structured.js"
|
|
3
|
+
import { erl_json_from, erl_json_to, normalize } from "./erl_json.js"
|
|
4
|
+
import { enc } from "./encode.js"
|
|
5
|
+
import { isBytes } from "./encode-utils.js"
|
|
6
|
+
import { createSigner as _createSigner } from "@permaweb/aoconnect"
|
|
7
|
+
import { toHttpSigner } from "./send.js"
|
|
8
|
+
|
|
9
|
+
// Export verify from signer-utils.js for compatibility
|
|
10
|
+
export { verify } from "./signer-utils.js"
|
|
11
|
+
|
|
12
|
+
// Helper to check if an array contains binary data
|
|
13
|
+
const arrayHasBinaryData = arr => {
|
|
14
|
+
if (!Array.isArray(arr)) return false
|
|
15
|
+
|
|
16
|
+
return arr.some(item => {
|
|
17
|
+
if (isBytes(item)) return true
|
|
18
|
+
if (Array.isArray(item)) return arrayHasBinaryData(item)
|
|
19
|
+
return false
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Helper to check if a string contains non-printable characters
|
|
24
|
+
const hasNonPrintableChars = str => {
|
|
25
|
+
if (typeof str !== "string") return false
|
|
26
|
+
|
|
27
|
+
for (let i = 0; i < str.length; i++) {
|
|
28
|
+
const code = str.charCodeAt(i)
|
|
29
|
+
// Allow only printable ASCII (32-126)
|
|
30
|
+
// Note: tabs (9), newlines (10), and carriage returns (13) are not allowed in HTTP headers
|
|
31
|
+
if (code < 32 || code > 126) {
|
|
32
|
+
return true
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return false
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const isValid = encoded => {
|
|
39
|
+
if (!encoded || typeof encoded !== "object") return false
|
|
40
|
+
|
|
41
|
+
// Check if all header values are valid for HTTP headers
|
|
42
|
+
for (const [key, value] of Object.entries(encoded)) {
|
|
43
|
+
if (key === "body") {
|
|
44
|
+
// Body can be string, Buffer, or undefined
|
|
45
|
+
if (
|
|
46
|
+
value !== undefined &&
|
|
47
|
+
typeof value !== "string" &&
|
|
48
|
+
!Buffer.isBuffer(value)
|
|
49
|
+
) {
|
|
50
|
+
return false
|
|
51
|
+
}
|
|
52
|
+
} else {
|
|
53
|
+
// All other fields (headers) must be strings or numbers
|
|
54
|
+
if (typeof value !== "string" && typeof value !== "number") {
|
|
55
|
+
// Check for Buffer in headers - this will fail HTTP signing
|
|
56
|
+
if (Buffer.isBuffer(value) || isBytes(value)) {
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Check if string contains non-printable characters
|
|
63
|
+
if (typeof value === "string" && hasNonPrintableChars(value)) {
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return true
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Check if object contains any binary data or arrays with binary data
|
|
73
|
+
const hasBinaryData = obj => {
|
|
74
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
75
|
+
if (key === "path") continue
|
|
76
|
+
|
|
77
|
+
if (isBytes(value)) {
|
|
78
|
+
return true
|
|
79
|
+
} else if (Array.isArray(value) && arrayHasBinaryData(value)) {
|
|
80
|
+
return true
|
|
81
|
+
} else if (
|
|
82
|
+
typeof value === "object" &&
|
|
83
|
+
value !== null &&
|
|
84
|
+
!Array.isArray(value)
|
|
85
|
+
) {
|
|
86
|
+
// Check nested objects
|
|
87
|
+
for (const [k, v] of Object.entries(value)) {
|
|
88
|
+
if (isBytes(v)) {
|
|
89
|
+
return true
|
|
90
|
+
} else if (Array.isArray(v) && arrayHasBinaryData(v)) {
|
|
91
|
+
return true
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return false
|
|
97
|
+
}
|
|
98
|
+
|
|
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"`)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return types.length > 0 ? types.join(", ") : null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Internal encode function that uses the original impl as much as possible
|
|
118
|
+
const encode = async (obj, path) => {
|
|
119
|
+
// Filter out undefined values before processing
|
|
120
|
+
const filtered = filterUndefined(obj)
|
|
121
|
+
|
|
122
|
+
// If object contains binary data, use enc() directly
|
|
123
|
+
if (hasBinaryData(filtered)) {
|
|
124
|
+
return await enc(filtered)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Only add path if explicitly provided
|
|
128
|
+
let fields = { ...filtered }
|
|
129
|
+
if (path) fields.path = path
|
|
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
|
+
|
|
140
|
+
// Try the standard encoding pipeline
|
|
141
|
+
const encoded = httpsig_to(normalize(structured_from(normalize(fields))))
|
|
142
|
+
|
|
143
|
+
// Check if the encoded result is valid for HTTP headers
|
|
144
|
+
if (!isValid(encoded)) {
|
|
145
|
+
// If invalid, fall back to enc()
|
|
146
|
+
const encResult = await enc(filtered)
|
|
147
|
+
return encResult
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// For non-binary data, return in the same format as enc()
|
|
151
|
+
// httpsig_to returns a flattened object, so we need to separate headers and body
|
|
152
|
+
const { body, ...headers } = encoded
|
|
153
|
+
return { headers, body }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Helper to join URL and path
|
|
157
|
+
const joinUrl = ({ url, path }) => {
|
|
158
|
+
if (path.startsWith("http://") || path.startsWith("https://")) return path
|
|
159
|
+
const normalizedPath = path.startsWith("/") ? path : "/" + path
|
|
160
|
+
return url.endsWith("/")
|
|
161
|
+
? url.slice(0, -1) + normalizedPath
|
|
162
|
+
: url + normalizedPath
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Main sign function that matches signer.js API
|
|
166
|
+
export async function sign({ url, path, msg: encoded, jwk, signPath = true }) {
|
|
167
|
+
const signer = _createSigner(jwk, url)
|
|
168
|
+
const { body = null, ...headers } = encoded
|
|
169
|
+
let _enc = { headers }
|
|
170
|
+
if (body) _enc.body = new Blob([body])
|
|
171
|
+
return await _sign({ path, signPath, encoded, signer, url })
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Helper function to recursively filter out undefined values
|
|
175
|
+
const filterUndefined = obj => {
|
|
176
|
+
if (obj === null || obj === undefined) return obj
|
|
177
|
+
if (Array.isArray(obj)) {
|
|
178
|
+
return obj.map(filterUndefined).filter(item => item !== undefined)
|
|
179
|
+
}
|
|
180
|
+
if (typeof obj === "object" && obj.constructor === Object) {
|
|
181
|
+
const filtered = {}
|
|
182
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
183
|
+
const filteredValue = filterUndefined(value)
|
|
184
|
+
if (filteredValue !== undefined) {
|
|
185
|
+
filtered[key] = filteredValue
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return filtered
|
|
189
|
+
}
|
|
190
|
+
return obj
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function _sign({
|
|
194
|
+
path,
|
|
195
|
+
signPath = true,
|
|
196
|
+
method = "POST",
|
|
197
|
+
encoded,
|
|
198
|
+
signer,
|
|
199
|
+
url,
|
|
200
|
+
}) {
|
|
201
|
+
const headersObj = encoded ? encoded.headers : {}
|
|
202
|
+
const body = encoded ? encoded.body : undefined
|
|
203
|
+
let url_path = typeof signPath === "string" ? signPath : path
|
|
204
|
+
const _url = joinUrl({ url, path: url_path })
|
|
205
|
+
|
|
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"
|
|
214
|
+
|
|
215
|
+
if (body && !headersObj["content-length"]) {
|
|
216
|
+
const bodySize = body.size || body.byteLength || 0
|
|
217
|
+
if (bodySize > 0) headersObj["content-length"] = String(bodySize)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const lowercaseHeaders = {}
|
|
221
|
+
for (const [key, value] of Object.entries(headersObj)) {
|
|
222
|
+
lowercaseHeaders[key.toLowerCase()] = value
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const bodyKeys = headersObj["body-keys"]
|
|
226
|
+
? headersObj["body-keys"]
|
|
227
|
+
.replace(/"/g, "")
|
|
228
|
+
.split(",")
|
|
229
|
+
.map(k => k.trim())
|
|
230
|
+
: []
|
|
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
|
+
}
|
|
243
|
+
let isPath = false
|
|
244
|
+
const signingFields = Object.keys(lowercaseHeaders).filter(key => {
|
|
245
|
+
if (key === "path") isPath = true
|
|
246
|
+
return !metadataFields.includes(key) && !bodyKeys.includes(key)
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
// Only add @path if signPath is enabled AND path header exists
|
|
250
|
+
if (signPath !== false && isPath) signingFields.push("@path")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
const signedRequest = await toHttpSigner(signer)({
|
|
254
|
+
request: { url: _url, method, headers: lowercaseHeaders },
|
|
255
|
+
fields: signingFields,
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
const finalHeaders = {}
|
|
259
|
+
for (const [key, value] of Object.entries(headersObj)) {
|
|
260
|
+
finalHeaders[key] = value
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
finalHeaders["signature"] = signedRequest.headers["signature"]
|
|
264
|
+
finalHeaders["signature-input"] = signedRequest.headers["signature-input"]
|
|
265
|
+
|
|
266
|
+
if (headersObj["body-keys"]) {
|
|
267
|
+
finalHeaders["body-keys"] = headersObj["body-keys"]
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const result = { url: _url, method, headers: finalHeaders }
|
|
271
|
+
if (body) result.body = body
|
|
272
|
+
|
|
273
|
+
return result
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function signer(config) {
|
|
277
|
+
const { signer, url = "http://localhost:10001" } = config
|
|
278
|
+
if (!signer) throw new Error("Signer is required for mainnet mode")
|
|
279
|
+
return async (
|
|
280
|
+
fields,
|
|
281
|
+
{ encoded: _encoded = false, path: signPath = true } = {}
|
|
282
|
+
) => {
|
|
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
|
+
|
|
301
|
+
const filteredFields = filterUndefined(aoFields)
|
|
302
|
+
const encoded = _encoded
|
|
303
|
+
? filteredFields
|
|
304
|
+
: await encode(filteredFields, null)
|
|
305
|
+
return await _sign({ path, signPath, method, encoded, signer, url })
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export const createSigner = (jwk, url) => {
|
|
310
|
+
const _signer = _createSigner(jwk, url)
|
|
311
|
+
return signer({ signer: _signer, url })
|
|
312
|
+
}
|