hbsig 0.3.1 → 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 (94) 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 -16
  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/{cjs → dist/cjs}/bin_to_str.js +0 -0
  47. /package/{cjs → dist/cjs}/collect-body-keys.js +0 -0
  48. /package/{cjs → dist/cjs}/commit.js +0 -0
  49. /package/{cjs → dist/cjs}/encode-array-item.js +0 -0
  50. /package/{cjs → dist/cjs}/encode-utils.js +0 -0
  51. /package/{cjs → dist/cjs}/encode.js +0 -0
  52. /package/{cjs → dist/cjs}/erl_json.js +0 -0
  53. /package/{cjs → dist/cjs}/erl_str.js +0 -0
  54. /package/{cjs → dist/cjs}/flat.js +0 -0
  55. /package/{cjs → dist/cjs}/http-message-signatures/httpbis.js +0 -0
  56. /package/{cjs → dist/cjs}/http-message-signatures/index.js +0 -0
  57. /package/{cjs → dist/cjs}/http-message-signatures/structured-header.js +0 -0
  58. /package/{cjs → dist/cjs}/httpsig.js +0 -0
  59. /package/{cjs → dist/cjs}/id.js +0 -0
  60. /package/{cjs → dist/cjs}/index.js +0 -0
  61. /package/{cjs → dist/cjs}/nocrypto.js +0 -0
  62. /package/{cjs → dist/cjs}/parser.js +0 -0
  63. /package/{cjs → dist/cjs}/send-utils.js +0 -0
  64. /package/{cjs → dist/cjs}/send.js +0 -0
  65. /package/{cjs → dist/cjs}/signer-utils.js +0 -0
  66. /package/{cjs → dist/cjs}/signer.js +0 -0
  67. /package/{cjs → dist/cjs}/structured.js +0 -0
  68. /package/{cjs → dist/cjs}/test.js +0 -0
  69. /package/{cjs → dist/cjs}/utils.js +0 -0
  70. /package/{esm → dist/esm}/bin_to_str.js +0 -0
  71. /package/{esm → dist/esm}/collect-body-keys.js +0 -0
  72. /package/{esm → dist/esm}/commit.js +0 -0
  73. /package/{esm → dist/esm}/encode-array-item.js +0 -0
  74. /package/{esm → dist/esm}/encode-utils.js +0 -0
  75. /package/{esm → dist/esm}/encode.js +0 -0
  76. /package/{esm → dist/esm}/erl_json.js +0 -0
  77. /package/{esm → dist/esm}/erl_str.js +0 -0
  78. /package/{esm → dist/esm}/flat.js +0 -0
  79. /package/{esm → dist/esm}/http-message-signatures/httpbis.js +0 -0
  80. /package/{esm → dist/esm}/http-message-signatures/index.js +0 -0
  81. /package/{esm → dist/esm}/http-message-signatures/structured-header.js +0 -0
  82. /package/{esm → dist/esm}/httpsig.js +0 -0
  83. /package/{esm → dist/esm}/id.js +0 -0
  84. /package/{esm → dist/esm}/index.js +0 -0
  85. /package/{esm → dist/esm}/nocrypto.js +0 -0
  86. /package/{esm → dist/esm}/package.json +0 -0
  87. /package/{esm → dist/esm}/parser.js +0 -0
  88. /package/{esm → dist/esm}/send-utils.js +0 -0
  89. /package/{esm → dist/esm}/send.js +0 -0
  90. /package/{esm → dist/esm}/signer-utils.js +0 -0
  91. /package/{esm → dist/esm}/signer.js +0 -0
  92. /package/{esm → dist/esm}/structured.js +0 -0
  93. /package/{esm → dist/esm}/test.js +0 -0
  94. /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
+ }