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/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"
|
package/src/nocrypto.js
ADDED
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
|
+
}
|