hbsig 0.0.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/bin_to_str.js +44 -0
- package/cjs/collect-body-keys.js +470 -0
- package/cjs/encode-array-item.js +110 -0
- package/cjs/encode-utils.js +236 -0
- package/cjs/encode.js +1318 -0
- package/cjs/erl_json.js +317 -0
- package/cjs/erl_str.js +1037 -0
- package/cjs/flat.js +222 -0
- package/cjs/http-message-signatures/httpbis.js +489 -0
- package/cjs/http-message-signatures/index.js +25 -0
- package/cjs/http-message-signatures/structured-header.js +129 -0
- package/cjs/httpsig.js +716 -0
- package/cjs/httpsig2.js +1160 -0
- package/cjs/id.js +470 -0
- package/cjs/index.js +63 -0
- package/cjs/send.js +194 -0
- package/cjs/signer-utils.js +617 -0
- package/cjs/signer.js +606 -0
- package/cjs/structured.js +296 -0
- package/cjs/test.js +27 -0
- package/cjs/utils.js +42 -0
- package/esm/bin_to_str.js +46 -0
- package/esm/collect-body-keys.js +436 -0
- package/esm/encode-array-item.js +112 -0
- package/esm/encode-utils.js +185 -0
- package/esm/encode.js +1219 -0
- package/esm/erl_json.js +289 -0
- package/esm/erl_str.js +1139 -0
- package/esm/flat.js +196 -0
- package/esm/http-message-signatures/httpbis.js +438 -0
- package/esm/http-message-signatures/index.js +4 -0
- package/esm/http-message-signatures/structured-header.js +105 -0
- package/esm/httpsig.js +658 -0
- package/esm/httpsig2.js +1097 -0
- package/esm/id.js +459 -0
- package/esm/index.js +4 -0
- package/esm/package.json +3 -0
- package/esm/send.js +124 -0
- package/esm/signer-utils.js +494 -0
- package/esm/signer.js +452 -0
- package/esm/structured.js +269 -0
- package/esm/test.js +6 -0
- package/esm/utils.js +28 -0
- package/package.json +28 -0
package/esm/flat.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A codec for turning nested objects into/from flat objects that have
|
|
3
|
+
* (potentially multi-layer) paths as their keys, and values as their values.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Convert a flat map to a nested object.
|
|
8
|
+
* @param {Object|string} input - Either a flat object or a serialized string
|
|
9
|
+
* @returns {Object} - Nested object
|
|
10
|
+
*/
|
|
11
|
+
export function flat_from(input) {
|
|
12
|
+
if (typeof input === "string" || input instanceof Buffer) {
|
|
13
|
+
// If input is binary/string, deserialize it first
|
|
14
|
+
return deserialize(input).ok
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (typeof input !== "object" || input === null) {
|
|
18
|
+
throw new Error("Input must be an object or string")
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const result = {}
|
|
22
|
+
|
|
23
|
+
for (const [pathKey, value] of Object.entries(input)) {
|
|
24
|
+
const pathParts = pathToParts(pathKey)
|
|
25
|
+
injectAtPath(pathParts, value, result)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return result
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Convert a nested object to a flat map.
|
|
33
|
+
* @param {Object|string} input - Either a nested object or a binary string (passthrough)
|
|
34
|
+
* @returns {Object|string} - Flat object or passthrough string
|
|
35
|
+
*/
|
|
36
|
+
export function flat_to(input) {
|
|
37
|
+
if (typeof input === "string" || input instanceof Buffer) {
|
|
38
|
+
// Binary passthrough
|
|
39
|
+
return input
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (typeof input !== "object" || input === null) {
|
|
43
|
+
throw new Error("Input must be an object or string")
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const result = {}
|
|
47
|
+
flattenRecursive(input, [], result)
|
|
48
|
+
return result
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Helper function to convert a path string to path parts
|
|
53
|
+
* @param {string|Array} path - Path string like "a/b/c" or array of parts
|
|
54
|
+
* @returns {Array} - Array of path parts
|
|
55
|
+
*/
|
|
56
|
+
function pathToParts(path) {
|
|
57
|
+
if (Array.isArray(path)) {
|
|
58
|
+
// Handle array paths
|
|
59
|
+
if (path.length === 1 && Array.isArray(path[0])) {
|
|
60
|
+
return path[0]
|
|
61
|
+
}
|
|
62
|
+
return path
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (typeof path === "string") {
|
|
66
|
+
// Split by '/' but handle edge cases
|
|
67
|
+
return path.split("/")
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
throw new Error("Path must be a string or array")
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Helper function to inject a value at a specific path in a nested object
|
|
75
|
+
* @param {Array} pathParts - Array of path parts
|
|
76
|
+
* @param {*} value - Value to inject
|
|
77
|
+
* @param {Object} obj - Object to inject into
|
|
78
|
+
*/
|
|
79
|
+
function injectAtPath(pathParts, value, obj) {
|
|
80
|
+
if (pathParts.length === 0) {
|
|
81
|
+
throw new Error("Path cannot be empty")
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (pathParts.length === 1) {
|
|
85
|
+
const key = pathParts[0]
|
|
86
|
+
|
|
87
|
+
if (key in obj) {
|
|
88
|
+
const existing = obj[key]
|
|
89
|
+
|
|
90
|
+
// If both are objects, merge them
|
|
91
|
+
if (
|
|
92
|
+
typeof existing === "object" &&
|
|
93
|
+
existing !== null &&
|
|
94
|
+
typeof value === "object" &&
|
|
95
|
+
value !== null &&
|
|
96
|
+
!Array.isArray(existing) &&
|
|
97
|
+
!Array.isArray(value)
|
|
98
|
+
) {
|
|
99
|
+
obj[key] = { ...existing, ...value }
|
|
100
|
+
} else {
|
|
101
|
+
// Path collision
|
|
102
|
+
throw new Error(
|
|
103
|
+
`Path collision at key: ${key}, existing: ${JSON.stringify(existing)}, value: ${JSON.stringify(value)}`
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
} else {
|
|
107
|
+
obj[key] = value
|
|
108
|
+
}
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const [key, ...rest] = pathParts
|
|
113
|
+
|
|
114
|
+
if (!(key in obj)) {
|
|
115
|
+
obj[key] = {}
|
|
116
|
+
} else if (typeof obj[key] !== "object" || obj[key] === null) {
|
|
117
|
+
throw new Error(`Cannot create nested path at non-object key: ${key}`)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
injectAtPath(rest, value, obj[key])
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Helper function to recursively flatten an object
|
|
125
|
+
* @param {*} value - Current value
|
|
126
|
+
* @param {Array} currentPath - Current path parts
|
|
127
|
+
* @param {Object} result - Result object to populate
|
|
128
|
+
*/
|
|
129
|
+
function flattenRecursive(value, currentPath, result) {
|
|
130
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
131
|
+
// It's an object, recurse into it
|
|
132
|
+
for (const [key, subValue] of Object.entries(value)) {
|
|
133
|
+
const newPath = [...currentPath, key]
|
|
134
|
+
flattenRecursive(subValue, newPath, result)
|
|
135
|
+
}
|
|
136
|
+
} else if (typeof value === "string") {
|
|
137
|
+
// It's a string leaf value (matching Erlang binary type)
|
|
138
|
+
if (currentPath.length === 0) {
|
|
139
|
+
throw new Error("Cannot flatten a non-object at root level")
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const pathKey = currentPath.join("/")
|
|
143
|
+
result[pathKey] = value
|
|
144
|
+
} else {
|
|
145
|
+
// Non-string leaf values cause errors in Erlang codec
|
|
146
|
+
throw new Error(
|
|
147
|
+
`Value type ${typeof value} not supported. Only strings and objects are allowed.`
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Serialize a map to a string format
|
|
154
|
+
* @param {Object} map - The map to serialize
|
|
155
|
+
* @returns {Object} - {ok: string} or {error: string}
|
|
156
|
+
*/
|
|
157
|
+
function serialize(map) {
|
|
158
|
+
try {
|
|
159
|
+
const flattened = flat_to(map)
|
|
160
|
+
const lines = []
|
|
161
|
+
|
|
162
|
+
for (const [key, value] of Object.entries(flattened)) {
|
|
163
|
+
lines.push(`${key}: ${value}`)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return { ok: lines.join("\n") + (lines.length > 0 ? "\n" : "") }
|
|
167
|
+
} catch (error) {
|
|
168
|
+
return { error: error.message }
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Deserialize a string to a map
|
|
174
|
+
* @param {string} input - The string to deserialize
|
|
175
|
+
* @returns {Object} - {ok: Object} or {error: string}
|
|
176
|
+
*/
|
|
177
|
+
function deserialize(input) {
|
|
178
|
+
try {
|
|
179
|
+
const str = input.toString()
|
|
180
|
+
const lines = str.split("\n").filter(line => line.trim())
|
|
181
|
+
const flat = {}
|
|
182
|
+
|
|
183
|
+
for (const line of lines) {
|
|
184
|
+
const colonIndex = line.indexOf(": ")
|
|
185
|
+
if (colonIndex !== -1) {
|
|
186
|
+
const key = line.substring(0, colonIndex)
|
|
187
|
+
const value = line.substring(colonIndex + 2)
|
|
188
|
+
flat[key] = value
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return { ok: flat_from(flat) }
|
|
193
|
+
} catch (error) {
|
|
194
|
+
return { error: error.message }
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
import {
|
|
2
|
+
parseDictionary,
|
|
3
|
+
parseItem,
|
|
4
|
+
serializeItem,
|
|
5
|
+
serializeList,
|
|
6
|
+
ByteSequence,
|
|
7
|
+
serializeDictionary,
|
|
8
|
+
parseList,
|
|
9
|
+
isInnerList,
|
|
10
|
+
isByteSequence,
|
|
11
|
+
Token,
|
|
12
|
+
} from "structured-headers"
|
|
13
|
+
import { Dictionary, parseHeader, quoteString } from "./structured-header.js"
|
|
14
|
+
|
|
15
|
+
// Default params if not provided
|
|
16
|
+
const defaultParams = ["created", "keyid", "alg", "expires"]
|
|
17
|
+
|
|
18
|
+
// Helper to check if message is a request
|
|
19
|
+
const isRequest = message => {
|
|
20
|
+
return message.method !== undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Components can be derived from requests or responses (which can also be bound to their request).
|
|
25
|
+
* The signature is essentially (component, params, signingSubject, supplementaryData)
|
|
26
|
+
*
|
|
27
|
+
* MODIFIED: This implementation matches Erlang behavior where @ components are treated as field lookups
|
|
28
|
+
*/
|
|
29
|
+
export function deriveComponent(component, params, message, req) {
|
|
30
|
+
// MODIFIED: Match Erlang behavior - all @ components become field lookups
|
|
31
|
+
// The Erlang strips @ and calls extract_field for ALL derived components
|
|
32
|
+
|
|
33
|
+
// Remove @ prefix to match Erlang behavior
|
|
34
|
+
const fieldName = component.startsWith("@") ? component.slice(1) : component
|
|
35
|
+
|
|
36
|
+
// For all @ components, do a field lookup instead of deriving
|
|
37
|
+
// This matches the Erlang identifier_to_component behavior
|
|
38
|
+
if (component.startsWith("@")) {
|
|
39
|
+
try {
|
|
40
|
+
if (params.has("req") && req) {
|
|
41
|
+
return extractHeader(fieldName, params, req, undefined)
|
|
42
|
+
}
|
|
43
|
+
return extractHeader(fieldName, params, message, req)
|
|
44
|
+
} catch (e) {
|
|
45
|
+
// If field not found, return empty or throw based on component type
|
|
46
|
+
throw new Error(
|
|
47
|
+
`No field "${fieldName}" found for component "${component}"`
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// This code path should not be reached for @ components anymore
|
|
53
|
+
// but keeping it for non-@ components
|
|
54
|
+
throw new Error(`Unsupported component "${component}"`)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function extractHeader(header, params, messageOrHeaders, req) {
|
|
58
|
+
const headers = messageOrHeaders.headers || messageOrHeaders
|
|
59
|
+
const context = params.has("req") ? req?.headers : headers
|
|
60
|
+
if (!context) {
|
|
61
|
+
throw new Error("Missing request in request-response bound component")
|
|
62
|
+
}
|
|
63
|
+
const headerTuple = Object.entries(context).find(
|
|
64
|
+
([name]) => name.toLowerCase() === header
|
|
65
|
+
)
|
|
66
|
+
if (!headerTuple) {
|
|
67
|
+
throw new Error(`No header "${header}" found in headers`)
|
|
68
|
+
}
|
|
69
|
+
const values = Array.isArray(headerTuple[1])
|
|
70
|
+
? headerTuple[1]
|
|
71
|
+
: [headerTuple[1]]
|
|
72
|
+
if (params.has("bs") && (params.has("sf") || params.has("key"))) {
|
|
73
|
+
throw new Error("Cannot have both `bs` and (implicit) `sf` parameters")
|
|
74
|
+
}
|
|
75
|
+
if (params.has("sf") || params.has("key")) {
|
|
76
|
+
// strict encoding of field
|
|
77
|
+
const value = values.join(", ")
|
|
78
|
+
const parsed = parseHeader(value)
|
|
79
|
+
if (params.has("key") && !(parsed instanceof Dictionary)) {
|
|
80
|
+
throw new Error("Unable to parse header as dictionary")
|
|
81
|
+
}
|
|
82
|
+
if (params.has("key")) {
|
|
83
|
+
const key = params.get("key").toString()
|
|
84
|
+
if (!parsed.has(key)) {
|
|
85
|
+
throw new Error(`Unable to find key "${key}" in structured field`)
|
|
86
|
+
}
|
|
87
|
+
return [parsed.get(key)]
|
|
88
|
+
}
|
|
89
|
+
return [parsed.toString()]
|
|
90
|
+
}
|
|
91
|
+
if (params.has("bs")) {
|
|
92
|
+
return [
|
|
93
|
+
values
|
|
94
|
+
.map(val => {
|
|
95
|
+
const encoded = Buffer.from(val.trim().replace(/\n\s*/gm, " "))
|
|
96
|
+
return `:${encoded.toString("base64")}:`
|
|
97
|
+
})
|
|
98
|
+
.join(", "),
|
|
99
|
+
]
|
|
100
|
+
}
|
|
101
|
+
// raw encoding
|
|
102
|
+
return [values.map(val => val.trim().replace(/\n\s*/gm, " ")).join(", ")]
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function normaliseParams(params) {
|
|
106
|
+
const map = new Map()
|
|
107
|
+
params.forEach((value, key) => {
|
|
108
|
+
if (value instanceof ByteSequence) {
|
|
109
|
+
map.set(key, value.toBase64())
|
|
110
|
+
} else if (value instanceof Token) {
|
|
111
|
+
map.set(key, value.toString())
|
|
112
|
+
} else {
|
|
113
|
+
map.set(key, value)
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
return map
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function createSignatureBase(config, res, req) {
|
|
120
|
+
return config.fields.reduce((base, fieldName) => {
|
|
121
|
+
const [field, params] = parseItem(quoteString(fieldName))
|
|
122
|
+
const fieldParams = normaliseParams(params)
|
|
123
|
+
const lcFieldName = field.toLowerCase()
|
|
124
|
+
if (lcFieldName !== "@signature-params") {
|
|
125
|
+
let value = null
|
|
126
|
+
if (config.componentParser) {
|
|
127
|
+
value =
|
|
128
|
+
config.componentParser(lcFieldName, fieldParams, res, req) ?? null
|
|
129
|
+
}
|
|
130
|
+
if (value === null) {
|
|
131
|
+
value = field.startsWith("@")
|
|
132
|
+
? deriveComponent(lcFieldName, fieldParams, res, req)
|
|
133
|
+
: extractHeader(lcFieldName, fieldParams, res, req)
|
|
134
|
+
}
|
|
135
|
+
base.push([serializeItem([field, params]), value])
|
|
136
|
+
}
|
|
137
|
+
return base
|
|
138
|
+
}, [])
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function formatSignatureBase(base) {
|
|
142
|
+
return base
|
|
143
|
+
.map(([key, value]) => {
|
|
144
|
+
const quotedKey = serializeItem(parseItem(quoteString(key)))
|
|
145
|
+
|
|
146
|
+
// MODIFIED: Special handling to match Erlang behavior
|
|
147
|
+
// If the key is "@path", format it as "path" (without @) in the signature base
|
|
148
|
+
let formattedKey = quotedKey
|
|
149
|
+
if (quotedKey === '"@path"') {
|
|
150
|
+
formattedKey = '"path"'
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return value.map(val => `${formattedKey}: ${val}`).join("\n")
|
|
154
|
+
})
|
|
155
|
+
.join("\n")
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function createSigningParameters(config) {
|
|
159
|
+
const now = new Date()
|
|
160
|
+
return (config.params ?? defaultParams).reduce((params, paramName) => {
|
|
161
|
+
let value = ""
|
|
162
|
+
switch (paramName.toLowerCase()) {
|
|
163
|
+
case "created":
|
|
164
|
+
// created is optional but recommended. If created is supplied but is null, that's an explicit
|
|
165
|
+
// instruction to *not* include the created parameter
|
|
166
|
+
if (config.paramValues?.created !== null) {
|
|
167
|
+
const created = config.paramValues?.created ?? now
|
|
168
|
+
value = Math.floor(created.getTime() / 1000)
|
|
169
|
+
}
|
|
170
|
+
break
|
|
171
|
+
case "expires":
|
|
172
|
+
// attempt to obtain an explicit expires time, otherwise create one that is 300 seconds after
|
|
173
|
+
// creation. Don't add an expires time if there is no created time
|
|
174
|
+
if (
|
|
175
|
+
config.paramValues?.expires ||
|
|
176
|
+
config.paramValues?.created !== null
|
|
177
|
+
) {
|
|
178
|
+
const expires =
|
|
179
|
+
config.paramValues?.expires ??
|
|
180
|
+
new Date((config.paramValues?.created ?? now).getTime() + 300000)
|
|
181
|
+
value = Math.floor(expires.getTime() / 1000)
|
|
182
|
+
}
|
|
183
|
+
break
|
|
184
|
+
case "keyid": {
|
|
185
|
+
// attempt to obtain the keyid omit if missing
|
|
186
|
+
const kid = config.paramValues?.keyid ?? config.key.id ?? null
|
|
187
|
+
if (kid) {
|
|
188
|
+
value = kid.toString()
|
|
189
|
+
}
|
|
190
|
+
break
|
|
191
|
+
}
|
|
192
|
+
case "alg": {
|
|
193
|
+
// if there is no alg, but it's listed as a required parameter, we should probably
|
|
194
|
+
// throw an error - the problem is that if it's in the default set of params, do we
|
|
195
|
+
// really want to throw if there's no keyid?
|
|
196
|
+
const alg = config.paramValues?.alg ?? config.key.alg ?? null
|
|
197
|
+
if (alg) {
|
|
198
|
+
value = alg.toString()
|
|
199
|
+
}
|
|
200
|
+
break
|
|
201
|
+
}
|
|
202
|
+
default:
|
|
203
|
+
if (config.paramValues?.[paramName] instanceof Date) {
|
|
204
|
+
value = Math.floor(config.paramValues[paramName].getTime() / 1000)
|
|
205
|
+
} else if (config.paramValues?.[paramName]) {
|
|
206
|
+
value = config.paramValues[paramName]
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (value) {
|
|
210
|
+
params.set(paramName, value)
|
|
211
|
+
}
|
|
212
|
+
return params
|
|
213
|
+
}, new Map())
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function augmentHeaders(headers, signature, signatureInput, name) {
|
|
217
|
+
let signatureHeaderName = "Signature"
|
|
218
|
+
let signatureInputHeaderName = "Signature-Input"
|
|
219
|
+
let signatureHeader = new Map()
|
|
220
|
+
let inputHeader = new Map()
|
|
221
|
+
// check to see if there are already signature/signature-input headers
|
|
222
|
+
// if there are we want to store the current (case-sensitive) name of the header
|
|
223
|
+
// and we want to parse out the current values so we can append our new signature
|
|
224
|
+
for (const header in headers) {
|
|
225
|
+
switch (header.toLowerCase()) {
|
|
226
|
+
case "signature": {
|
|
227
|
+
signatureHeaderName = header
|
|
228
|
+
signatureHeader = parseDictionary(
|
|
229
|
+
Array.isArray(headers[header])
|
|
230
|
+
? headers[header].join(", ")
|
|
231
|
+
: headers[header]
|
|
232
|
+
)
|
|
233
|
+
break
|
|
234
|
+
}
|
|
235
|
+
case "signature-input":
|
|
236
|
+
signatureInputHeaderName = header
|
|
237
|
+
inputHeader = parseDictionary(
|
|
238
|
+
Array.isArray(headers[header])
|
|
239
|
+
? headers[header].join(", ")
|
|
240
|
+
: headers[header]
|
|
241
|
+
)
|
|
242
|
+
break
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// find a unique signature name for the header. Check if any existing headers already use
|
|
246
|
+
// the name we intend to use, if there are, add incrementing numbers to the signature name
|
|
247
|
+
// until we have a unique name to use
|
|
248
|
+
let signatureName = name ?? "sig"
|
|
249
|
+
if (signatureHeader.has(signatureName) || inputHeader.has(signatureName)) {
|
|
250
|
+
let count = 0
|
|
251
|
+
while (
|
|
252
|
+
signatureHeader.has(`${signatureName}${count}`) ||
|
|
253
|
+
inputHeader.has(`${signatureName}${count}`)
|
|
254
|
+
) {
|
|
255
|
+
count++
|
|
256
|
+
}
|
|
257
|
+
signatureName += count.toString()
|
|
258
|
+
}
|
|
259
|
+
// append our signature and signature-inputs to the headers and return
|
|
260
|
+
signatureHeader.set(signatureName, [
|
|
261
|
+
new ByteSequence(signature.toString("base64")),
|
|
262
|
+
new Map(),
|
|
263
|
+
])
|
|
264
|
+
inputHeader.set(signatureName, parseList(signatureInput)[0])
|
|
265
|
+
return {
|
|
266
|
+
...headers,
|
|
267
|
+
[signatureHeaderName]: serializeDictionary(signatureHeader),
|
|
268
|
+
[signatureInputHeaderName]: serializeDictionary(inputHeader),
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export async function signMessage(config, message, req) {
|
|
273
|
+
const signingParameters = createSigningParameters(config)
|
|
274
|
+
const signatureBase = createSignatureBase(
|
|
275
|
+
{
|
|
276
|
+
fields: config.fields ?? [],
|
|
277
|
+
componentParser: config.componentParser,
|
|
278
|
+
},
|
|
279
|
+
message,
|
|
280
|
+
req
|
|
281
|
+
)
|
|
282
|
+
const signatureInput = serializeList([
|
|
283
|
+
[signatureBase.map(([item]) => parseItem(item)), signingParameters],
|
|
284
|
+
])
|
|
285
|
+
signatureBase.push(['"@signature-params"', [signatureInput]])
|
|
286
|
+
const base = formatSignatureBase(signatureBase)
|
|
287
|
+
// call sign
|
|
288
|
+
const signature = await config.key.sign(Buffer.from(base))
|
|
289
|
+
return {
|
|
290
|
+
...message,
|
|
291
|
+
headers: augmentHeaders(
|
|
292
|
+
{ ...message.headers },
|
|
293
|
+
signature,
|
|
294
|
+
signatureInput,
|
|
295
|
+
config.name
|
|
296
|
+
),
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export async function verifyMessage(config, message, req) {
|
|
301
|
+
const { signatures, signatureInputs } = Object.entries(
|
|
302
|
+
message.headers
|
|
303
|
+
).reduce((accum, [name, value]) => {
|
|
304
|
+
switch (name.toLowerCase()) {
|
|
305
|
+
case "signature":
|
|
306
|
+
return Object.assign(accum, {
|
|
307
|
+
signatures: parseDictionary(
|
|
308
|
+
Array.isArray(value) ? value.join(", ") : value
|
|
309
|
+
),
|
|
310
|
+
})
|
|
311
|
+
case "signature-input":
|
|
312
|
+
return Object.assign(accum, {
|
|
313
|
+
signatureInputs: parseDictionary(
|
|
314
|
+
Array.isArray(value) ? value.join(", ") : value
|
|
315
|
+
),
|
|
316
|
+
})
|
|
317
|
+
default:
|
|
318
|
+
return accum
|
|
319
|
+
}
|
|
320
|
+
}, {})
|
|
321
|
+
// no signatures means an indeterminate result
|
|
322
|
+
if (!signatures?.size && !signatureInputs?.size) {
|
|
323
|
+
return null
|
|
324
|
+
}
|
|
325
|
+
// a missing header means we can't verify the signatures
|
|
326
|
+
if (!signatures?.size || !signatureInputs?.size) {
|
|
327
|
+
throw new Error("Incomplete signature headers")
|
|
328
|
+
}
|
|
329
|
+
const now = Math.floor(Date.now() / 1000)
|
|
330
|
+
const tolerance = config.tolerance ?? 0
|
|
331
|
+
const notAfter =
|
|
332
|
+
config.notAfter instanceof Date
|
|
333
|
+
? Math.floor(config.notAfter.getTime() / 1000)
|
|
334
|
+
: (config.notAfter ?? now)
|
|
335
|
+
const maxAge = config.maxAge ?? null
|
|
336
|
+
const requiredParams = config.requiredParams ?? []
|
|
337
|
+
const requiredFields = config.requiredFields ?? []
|
|
338
|
+
return Array.from(signatureInputs.entries()).reduce(
|
|
339
|
+
async (prev, [name, input]) => {
|
|
340
|
+
const signatureParams = Array.from(input[1].entries()).reduce(
|
|
341
|
+
(params, [key, value]) => {
|
|
342
|
+
if (value instanceof ByteSequence) {
|
|
343
|
+
Object.assign(params, {
|
|
344
|
+
[key]: value.toBase64(),
|
|
345
|
+
})
|
|
346
|
+
} else if (value instanceof Token) {
|
|
347
|
+
Object.assign(params, {
|
|
348
|
+
[key]: value.toString(),
|
|
349
|
+
})
|
|
350
|
+
} else if (key === "created" || key === "expired") {
|
|
351
|
+
Object.assign(params, {
|
|
352
|
+
[key]: new Date(value * 1000),
|
|
353
|
+
})
|
|
354
|
+
} else {
|
|
355
|
+
Object.assign(params, {
|
|
356
|
+
[key]: value,
|
|
357
|
+
})
|
|
358
|
+
}
|
|
359
|
+
return params
|
|
360
|
+
},
|
|
361
|
+
{}
|
|
362
|
+
)
|
|
363
|
+
const [result, key] = await Promise.all([
|
|
364
|
+
prev.catch(e => e),
|
|
365
|
+
config.keyLookup(signatureParams),
|
|
366
|
+
])
|
|
367
|
+
// @todo - confirm this is all working as expected
|
|
368
|
+
if (config.all && !key) {
|
|
369
|
+
throw new Error("Unknown key")
|
|
370
|
+
}
|
|
371
|
+
if (!key) {
|
|
372
|
+
if (result instanceof Error) {
|
|
373
|
+
throw result
|
|
374
|
+
}
|
|
375
|
+
return result
|
|
376
|
+
}
|
|
377
|
+
if (
|
|
378
|
+
input[1].has("alg") &&
|
|
379
|
+
key.algs?.includes(input[1].get("alg")) === false
|
|
380
|
+
) {
|
|
381
|
+
throw new Error("Unsupported key algorithm")
|
|
382
|
+
}
|
|
383
|
+
if (!isInnerList(input)) {
|
|
384
|
+
throw new Error("Malformed signature input")
|
|
385
|
+
}
|
|
386
|
+
const hasRequiredParams = requiredParams.every(param =>
|
|
387
|
+
input[1].has(param)
|
|
388
|
+
)
|
|
389
|
+
if (!hasRequiredParams) {
|
|
390
|
+
throw new Error("Missing required signature parameters")
|
|
391
|
+
}
|
|
392
|
+
// this could be tricky, what if we say "@method" but there is "@method;req"
|
|
393
|
+
const hasRequiredFields = requiredFields.every(field =>
|
|
394
|
+
input[0].some(([fieldName]) => fieldName === field)
|
|
395
|
+
)
|
|
396
|
+
if (!hasRequiredFields) {
|
|
397
|
+
throw new Error("Missing required signed fields")
|
|
398
|
+
}
|
|
399
|
+
if (input[1].has("created")) {
|
|
400
|
+
const created = input[1].get("created") - tolerance
|
|
401
|
+
// maxAge overrides expires.
|
|
402
|
+
// signature is older than maxAge
|
|
403
|
+
if ((maxAge && now - created > maxAge) || created > notAfter) {
|
|
404
|
+
throw new Error("Signature is too old")
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (input[1].has("expires")) {
|
|
408
|
+
const expires = input[1].get("expires") + tolerance
|
|
409
|
+
// expired signature
|
|
410
|
+
if (now > expires) {
|
|
411
|
+
throw new Error("Signature has expired")
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
// now look to verify the signature! Build the expected "signing base" and verify it!
|
|
415
|
+
const fields = input[0].map(item => serializeItem(item))
|
|
416
|
+
const signingBase = createSignatureBase(
|
|
417
|
+
{ fields, componentParser: config.componentParser },
|
|
418
|
+
message,
|
|
419
|
+
req
|
|
420
|
+
)
|
|
421
|
+
signingBase.push(['"@signature-params"', [serializeList([input])]])
|
|
422
|
+
const base = formatSignatureBase(signingBase)
|
|
423
|
+
const signature = signatures.get(name)
|
|
424
|
+
if (!signature) {
|
|
425
|
+
throw new Error("No corresponding signature for input")
|
|
426
|
+
}
|
|
427
|
+
if (!isByteSequence(signature[0])) {
|
|
428
|
+
throw new Error("Malformed signature")
|
|
429
|
+
}
|
|
430
|
+
return key.verify(
|
|
431
|
+
Buffer.from(base),
|
|
432
|
+
Buffer.from(signature[0].toBase64(), "base64"),
|
|
433
|
+
signatureParams
|
|
434
|
+
)
|
|
435
|
+
},
|
|
436
|
+
Promise.resolve(null)
|
|
437
|
+
)
|
|
438
|
+
}
|