@cdxgen/cdxgen 13.2.0 → 13.3.0
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/README.md +28 -12
- package/bin/cdxgen.js +227 -119
- package/bin/evinse.js +5 -0
- package/bin/sign.js +88 -14
- package/bin/tracebom.js +64 -4
- package/bin/validate.js +29 -7
- package/bin/verify.js +79 -39
- package/data/remediations.json +24 -0
- package/lib/cli/bomAssembly.js +105 -15
- package/lib/cli/cliOptions.js +93 -1
- package/lib/cli/index.js +43 -4
- package/lib/cli/jsBom.js +49 -24
- package/lib/cli/jvmBom.js +46 -12
- package/lib/cli/managedBom.js +112 -41
- package/lib/cli/nativeBom.js +125 -14
- package/lib/core/env.js +5 -6
- package/lib/core/fs.js +174 -6
- package/lib/core/metadataProperties.js +97 -0
- package/lib/core/projectConfig.js +31 -4
- package/lib/ecosystems/bunutils.js +269 -101
- package/lib/ecosystems/caxa.js +434 -2
- package/lib/ecosystems/cmakeResolver.js +66 -0
- package/lib/ecosystems/cppEvidence.js +64 -31
- package/lib/ecosystems/ecosystems.js +19 -8
- package/lib/ecosystems/gradleutils.js +42 -7
- package/lib/ecosystems/jsEvidence.js +19 -20
- package/lib/ecosystems/kosi.js +10 -3
- package/lib/ecosystems/npmutils.js +110 -9
- package/lib/ecosystems/parsers-dotnet.js +51 -10
- package/lib/ecosystems/parsers-go.js +47 -9
- package/lib/ecosystems/parsers-js.js +171 -50
- package/lib/ecosystems/parsers-misc.js +747 -161
- package/lib/ecosystems/parsers-python.js +26 -14
- package/lib/ecosystems/parsers-rust.js +24 -18
- package/lib/ecosystems/rubyutils.js +1426 -166
- package/lib/ecosystems/rusi.js +32 -1
- package/lib/evinser/evinser.js +436 -128
- package/lib/evinser/swiftsem.js +1914 -302
- package/lib/helpers/bomSigner.js +652 -221
- package/lib/inventory/atomUtils.js +568 -31
- package/lib/inventory/cdxrs.js +103 -12
- package/lib/inventory/depsUtils.js +183 -11
- package/lib/inventory/display.js +4 -5
- package/lib/inventory/dosai.js +550 -13
- package/lib/inventory/dynamic.js +39 -1
- package/lib/inventory/evidenceUtils.js +14 -6
- package/lib/inventory/fetchBatch.js +116 -17
- package/lib/inventory/protobom.js +8 -1
- package/lib/inventory/purl.js +152 -3
- package/lib/inventory/sbomDocument.js +2 -70
- package/lib/inventory/source.js +37 -19
- package/lib/inventory/traceRunner.js +210 -21
- package/lib/managers/binary.js +115 -24
- package/lib/managers/docker.js +440 -20
- package/lib/managers/oci.js +39 -1
- package/lib/parsers/cmakeLists.js +452 -0
- package/lib/parsers/largeJson.js +454 -0
- package/lib/parsers/npmrc.js +19 -1
- package/lib/stages/postgen/postgen.js +68 -15
- package/lib/validator/bomValidator.js +6 -16
- package/lib/validator/index.js +48 -10
- package/lib/validator/reporters/console.js +3 -0
- package/lib/validator/reporters/json.js +1 -0
- package/lib/validator/reporters/sarif.js +7 -0
- package/package.json +20 -20
- package/types/lib/cli/bomAssembly.d.ts +2 -1
- package/types/lib/cli/bomAssembly.d.ts.map +1 -1
- package/types/lib/cli/cliOptions.d.ts +33 -1
- package/types/lib/cli/cliOptions.d.ts.map +1 -1
- package/types/lib/cli/index.d.ts.map +1 -1
- package/types/lib/cli/jsBom.d.ts.map +1 -1
- package/types/lib/cli/jvmBom.d.ts.map +1 -1
- package/types/lib/cli/managedBom.d.ts.map +1 -1
- package/types/lib/cli/nativeBom.d.ts.map +1 -1
- package/types/lib/core/env.d.ts.map +1 -1
- package/types/lib/core/fs.d.ts +55 -0
- package/types/lib/core/fs.d.ts.map +1 -1
- package/types/lib/core/metadataProperties.d.ts +39 -0
- package/types/lib/core/metadataProperties.d.ts.map +1 -0
- package/types/lib/core/projectConfig.d.ts +6 -2
- package/types/lib/core/projectConfig.d.ts.map +1 -1
- package/types/lib/ecosystems/bunutils.d.ts +12 -5
- package/types/lib/ecosystems/bunutils.d.ts.map +1 -1
- package/types/lib/ecosystems/caxa.d.ts +14 -0
- package/types/lib/ecosystems/caxa.d.ts.map +1 -1
- package/types/lib/ecosystems/cmakeResolver.d.ts +23 -0
- package/types/lib/ecosystems/cmakeResolver.d.ts.map +1 -1
- package/types/lib/ecosystems/cppEvidence.d.ts +1 -1
- package/types/lib/ecosystems/cppEvidence.d.ts.map +1 -1
- package/types/lib/ecosystems/ecosystems.d.ts.map +1 -1
- package/types/lib/ecosystems/golem.d.ts.map +1 -1
- package/types/lib/ecosystems/gradleutils.d.ts +20 -4
- package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
- package/types/lib/ecosystems/jsEvidence.d.ts +5 -2
- package/types/lib/ecosystems/jsEvidence.d.ts.map +1 -1
- package/types/lib/ecosystems/kosi.d.ts +13 -0
- package/types/lib/ecosystems/kosi.d.ts.map +1 -1
- package/types/lib/ecosystems/npmutils.d.ts +42 -0
- package/types/lib/ecosystems/npmutils.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-dotnet.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-go.d.ts +10 -0
- package/types/lib/ecosystems/parsers-go.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-js.d.ts +10 -4
- package/types/lib/ecosystems/parsers-js.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-misc.d.ts +67 -3
- package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-rust.d.ts.map +1 -1
- package/types/lib/ecosystems/pythonutils.d.ts +1 -1
- package/types/lib/ecosystems/rubyutils.d.ts +143 -2
- package/types/lib/ecosystems/rubyutils.d.ts.map +1 -1
- package/types/lib/ecosystems/rusi.d.ts +13 -0
- package/types/lib/ecosystems/rusi.d.ts.map +1 -1
- package/types/lib/evinser/evinser.d.ts +59 -9
- package/types/lib/evinser/evinser.d.ts.map +1 -1
- package/types/lib/evinser/swiftsem.d.ts +280 -15
- package/types/lib/evinser/swiftsem.d.ts.map +1 -1
- package/types/lib/helpers/bomSigner.d.ts +106 -13
- package/types/lib/helpers/bomSigner.d.ts.map +1 -1
- package/types/lib/inventory/atomUtils.d.ts +145 -3
- package/types/lib/inventory/atomUtils.d.ts.map +1 -1
- package/types/lib/inventory/cdxrs.d.ts +8 -3
- package/types/lib/inventory/cdxrs.d.ts.map +1 -1
- package/types/lib/inventory/depsUtils.d.ts +15 -0
- package/types/lib/inventory/depsUtils.d.ts.map +1 -1
- package/types/lib/inventory/display.d.ts.map +1 -1
- package/types/lib/inventory/dosai.d.ts +64 -1
- package/types/lib/inventory/dosai.d.ts.map +1 -1
- package/types/lib/inventory/dynamic.d.ts.map +1 -1
- package/types/lib/inventory/evidenceUtils.d.ts.map +1 -1
- package/types/lib/inventory/fetchBatch.d.ts.map +1 -1
- package/types/lib/inventory/plugins.d.ts.map +1 -1
- package/types/lib/inventory/protobom.d.ts.map +1 -1
- package/types/lib/inventory/purl.d.ts +71 -2
- package/types/lib/inventory/purl.d.ts.map +1 -1
- package/types/lib/inventory/sbomDocument.d.ts.map +1 -1
- package/types/lib/inventory/source.d.ts.map +1 -1
- package/types/lib/inventory/traceRunner.d.ts +47 -1
- package/types/lib/inventory/traceRunner.d.ts.map +1 -1
- package/types/lib/managers/binary.d.ts +30 -1
- package/types/lib/managers/binary.d.ts.map +1 -1
- package/types/lib/managers/docker.d.ts +29 -0
- package/types/lib/managers/docker.d.ts.map +1 -1
- package/types/lib/managers/oci.d.ts +14 -0
- package/types/lib/managers/oci.d.ts.map +1 -1
- package/types/lib/parsers/cmakeLists.d.ts +76 -0
- package/types/lib/parsers/cmakeLists.d.ts.map +1 -0
- package/types/lib/parsers/largeJson.d.ts +52 -0
- package/types/lib/parsers/largeJson.d.ts.map +1 -0
- package/types/lib/parsers/npmrc.d.ts +5 -0
- package/types/lib/parsers/npmrc.d.ts.map +1 -1
- package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
- package/types/lib/validator/bomValidator.d.ts.map +1 -1
- package/types/lib/validator/index.d.ts +10 -1
- package/types/lib/validator/index.d.ts.map +1 -1
- package/types/lib/validator/reporters/console.d.ts.map +1 -1
- package/types/lib/validator/reporters/json.d.ts.map +1 -1
- package/types/lib/validator/reporters/sarif.d.ts.map +1 -1
package/lib/helpers/bomSigner.js
CHANGED
|
@@ -1,137 +1,540 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* JSF algorithm identifiers and the key material each one requires.
|
|
5
|
+
*
|
|
6
|
+
* The algorithm named in a signature block is chosen by whoever produced the
|
|
7
|
+
* document, so it is only honoured when the key supplied by the signer or the
|
|
8
|
+
* verifier is exactly the type listed here.
|
|
9
|
+
*/
|
|
10
|
+
const JSF_ALGORITHMS = new Map([
|
|
11
|
+
["RS256", { keyType: "rsa", hash: "sha256" }],
|
|
12
|
+
["RS384", { keyType: "rsa", hash: "sha384" }],
|
|
13
|
+
["RS512", { keyType: "rsa", hash: "sha512" }],
|
|
14
|
+
["PS256", { keyType: "rsa-pss", hash: "sha256" }],
|
|
15
|
+
["PS384", { keyType: "rsa-pss", hash: "sha384" }],
|
|
16
|
+
["PS512", { keyType: "rsa-pss", hash: "sha512" }],
|
|
17
|
+
["ES256", { keyType: "ec", hash: "sha256", curve: "prime256v1" }],
|
|
18
|
+
["ES384", { keyType: "ec", hash: "sha384", curve: "secp384r1" }],
|
|
19
|
+
["ES512", { keyType: "ec", hash: "sha512", curve: "secp521r1" }],
|
|
20
|
+
["Ed25519", { keyType: "ed25519", hash: null }],
|
|
21
|
+
["Ed448", { keyType: "ed448", hash: null }],
|
|
22
|
+
["HS256", { keyType: "secret", hash: "sha256" }],
|
|
23
|
+
["HS384", { keyType: "secret", hash: "sha384" }],
|
|
24
|
+
["HS512", { keyType: "secret", hash: "sha512" }],
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
const SIGNATURE_MODES = ["replace", "signers", "chain"];
|
|
28
|
+
|
|
29
|
+
const NESTED_TARGETS = ["components", "services", "annotations"];
|
|
30
|
+
|
|
31
|
+
// DER layouts tried when checking whether raw bytes are really a key.
|
|
32
|
+
const DER_KEY_LAYOUTS = [
|
|
33
|
+
[crypto.createPublicKey, "spki"],
|
|
34
|
+
[crypto.createPublicKey, "pkcs1"],
|
|
35
|
+
[crypto.createPrivateKey, "pkcs8"],
|
|
36
|
+
[crypto.createPrivateKey, "pkcs1"],
|
|
37
|
+
[crypto.createPrivateKey, "sec1"],
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Renders a value taken from a document for a message. JSON can make any field
|
|
42
|
+
* an object or array, and interpolating those directly can throw.
|
|
43
|
+
*
|
|
44
|
+
* @param {any} value - Value to render
|
|
45
|
+
* @returns {string} - Printable text
|
|
46
|
+
*/
|
|
47
|
+
export function displayValue(value) {
|
|
48
|
+
if (typeof value === "string") {
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
return JSON.stringify(value) ?? String(value);
|
|
53
|
+
} catch {
|
|
54
|
+
return `<${typeof value}>`;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isPlainObject(value) {
|
|
59
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Deterministic JSON canonicalizer following RFC 8785 (JCS), which JSF uses to
|
|
64
|
+
* produce the signed data. Values are serialized the way JSON.stringify would
|
|
65
|
+
* see them, so an in-memory object and its parsed JSON form canonicalize
|
|
66
|
+
* identically.
|
|
6
67
|
*
|
|
7
68
|
* @param {any} value - The JSON object/value to canonicalize
|
|
8
|
-
* @returns {string} - Canonicalized JSON string
|
|
69
|
+
* @returns {string|undefined} - Canonicalized JSON string, or undefined for values JSON omits
|
|
9
70
|
*/
|
|
10
71
|
function canonicalize(value) {
|
|
72
|
+
if (typeof value?.toJSON === "function") {
|
|
73
|
+
value = value.toJSON();
|
|
74
|
+
}
|
|
11
75
|
if (value === null || typeof value !== "object") {
|
|
12
76
|
return JSON.stringify(value);
|
|
13
77
|
}
|
|
14
78
|
if (Array.isArray(value)) {
|
|
15
|
-
return `[${value.map(canonicalize).join(",")}]`;
|
|
79
|
+
return `[${value.map((item) => canonicalize(item) ?? "null").join(",")}]`;
|
|
80
|
+
}
|
|
81
|
+
const members = [];
|
|
82
|
+
for (const key of Object.keys(value).sort()) {
|
|
83
|
+
const member = canonicalize(value[key]);
|
|
84
|
+
if (member !== undefined) {
|
|
85
|
+
members.push(`${JSON.stringify(key)}:${member}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return `{${members.join(",")}}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function getAlgorithm(alg) {
|
|
92
|
+
const spec = typeof alg === "string" ? JSF_ALGORITHMS.get(alg) : undefined;
|
|
93
|
+
if (!spec) {
|
|
94
|
+
const name =
|
|
95
|
+
typeof alg === "string" ? alg : `<${alg === null ? "null" : typeof alg}>`;
|
|
96
|
+
throw new Error(
|
|
97
|
+
`Unsupported JSF algorithm: ${name}. Supported algorithms: ${[...JSF_ALGORITHMS.keys()].join(", ")}.`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return spec;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function describeRequirement(spec) {
|
|
104
|
+
if (spec.keyType === "secret") {
|
|
105
|
+
return "a shared secret";
|
|
106
|
+
}
|
|
107
|
+
if (spec.keyType === "rsa-pss") {
|
|
108
|
+
return "an rsa or rsa-pss key";
|
|
16
109
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
for (let i = 0; i < keys.length; i++) {
|
|
20
|
-
if (i > 0) str += ",";
|
|
21
|
-
str += `${JSON.stringify(keys[i])}:${canonicalize(value[keys[i]])}`;
|
|
110
|
+
if (spec.curve) {
|
|
111
|
+
return `an ec (${spec.curve}) key`;
|
|
22
112
|
}
|
|
23
|
-
|
|
24
|
-
|
|
113
|
+
return `an ${spec.keyType} key`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function describeKey(key) {
|
|
117
|
+
if (key.type === "secret") {
|
|
118
|
+
return "a shared secret";
|
|
119
|
+
}
|
|
120
|
+
const curve = key.asymmetricKeyDetails?.namedCurve;
|
|
121
|
+
return curve
|
|
122
|
+
? `${key.asymmetricKeyType} (${curve})`
|
|
123
|
+
: String(key.asymmetricKeyType);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function assertKeyMatchesAlgorithm(key, alg, spec) {
|
|
127
|
+
let matches;
|
|
128
|
+
if (spec.keyType === "secret" || key.type === "secret") {
|
|
129
|
+
matches = spec.keyType === "secret" && key.type === "secret";
|
|
130
|
+
} else if (spec.keyType === "rsa-pss") {
|
|
131
|
+
matches = ["rsa", "rsa-pss"].includes(key.asymmetricKeyType);
|
|
132
|
+
} else if (spec.keyType === "ec") {
|
|
133
|
+
matches =
|
|
134
|
+
key.asymmetricKeyType === "ec" &&
|
|
135
|
+
key.asymmetricKeyDetails?.namedCurve === spec.curve;
|
|
136
|
+
} else {
|
|
137
|
+
matches = key.asymmetricKeyType === spec.keyType;
|
|
138
|
+
}
|
|
139
|
+
if (!matches) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`Algorithm ${alg} requires ${describeRequirement(spec)}, but the key is ${describeKey(key)}.`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function parsesAsKey(create, input) {
|
|
147
|
+
try {
|
|
148
|
+
create(input);
|
|
149
|
+
return true;
|
|
150
|
+
} catch (err) {
|
|
151
|
+
return err?.code === "ERR_MISSING_PASSPHRASE";
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function isJwkText(material) {
|
|
156
|
+
try {
|
|
157
|
+
const parsed = JSON.parse(material.toString("utf8"));
|
|
158
|
+
return isPlainObject(parsed) && typeof parsed.kty === "string";
|
|
159
|
+
} catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function looksLikeAsymmetricKey(material) {
|
|
165
|
+
if (typeof material === "string" || Buffer.isBuffer(material)) {
|
|
166
|
+
if (material.includes("-----BEGIN ") || isJwkText(material)) {
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (
|
|
171
|
+
parsesAsKey(crypto.createPrivateKey, material) ||
|
|
172
|
+
parsesAsKey(crypto.createPublicKey, material)
|
|
173
|
+
) {
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
if (Buffer.isBuffer(material)) {
|
|
177
|
+
return DER_KEY_LAYOUTS.some(([create, type]) =>
|
|
178
|
+
parsesAsKey(create, { key: material, format: "der", type }),
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
return false;
|
|
25
182
|
}
|
|
26
183
|
|
|
27
184
|
/**
|
|
28
|
-
*
|
|
185
|
+
* Wraps shared-secret material for HMAC (HS256, HS384, HS512) signing or
|
|
186
|
+
* verification.
|
|
29
187
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* @param {Object} [publicKeyJwk] - Optional JWK representation of the public key
|
|
34
|
-
* @param {string} keyId - Key ID
|
|
188
|
+
* HMAC is only ever used with a key produced by this function (or any secret
|
|
189
|
+
* KeyObject). Public and private keys are refused, so a document cannot switch
|
|
190
|
+
* a verifier to HMAC and use its public key as the secret.
|
|
35
191
|
*
|
|
36
|
-
* @
|
|
192
|
+
* @param {string|Buffer|crypto.KeyObject} material - Secret bytes, a UTF-8 string, or a secret KeyObject
|
|
193
|
+
* @returns {crypto.KeyObject} - A secret KeyObject
|
|
37
194
|
*/
|
|
38
|
-
function
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
) {
|
|
45
|
-
// Exclude existing signatures from the canonicalized payload as per JSF
|
|
46
|
-
const { signature, ...dataToSign } = payload;
|
|
47
|
-
const canonicalData = canonicalize(dataToSign);
|
|
48
|
-
|
|
49
|
-
let hashAlg;
|
|
50
|
-
const signOptions = { key: privateKey };
|
|
51
|
-
|
|
52
|
-
// Handle HMAC (Symmetric)
|
|
53
|
-
if (alg.startsWith("HS")) {
|
|
54
|
-
const hash = alg.replace("HS", "sha");
|
|
55
|
-
const value = crypto
|
|
56
|
-
.createHmac(hash, privateKey)
|
|
57
|
-
.update(canonicalData, "utf8")
|
|
58
|
-
.digest("base64url");
|
|
59
|
-
const block = { algorithm: alg, value };
|
|
60
|
-
if (publicKeyJwk) {
|
|
61
|
-
block.publicKey = publicKeyJwk;
|
|
195
|
+
export function loadSharedSecret(material) {
|
|
196
|
+
if (material instanceof crypto.KeyObject) {
|
|
197
|
+
if (material.type !== "secret") {
|
|
198
|
+
throw new Error(
|
|
199
|
+
"HMAC signatures need a shared secret, but an asymmetric key was supplied.",
|
|
200
|
+
);
|
|
62
201
|
}
|
|
63
|
-
|
|
64
|
-
|
|
202
|
+
return material;
|
|
203
|
+
}
|
|
204
|
+
if (typeof material !== "string" && !Buffer.isBuffer(material)) {
|
|
205
|
+
throw new TypeError(
|
|
206
|
+
"The shared secret must be a string, a Buffer, or a secret KeyObject.",
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
if (looksLikeAsymmetricKey(material)) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
"The shared secret looks like a public or private key. HMAC signatures must use a separate shared secret.",
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
const bytes =
|
|
215
|
+
typeof material === "string" ? Buffer.from(material, "utf8") : material;
|
|
216
|
+
if (!bytes.length) {
|
|
217
|
+
throw new Error("The shared secret is empty.");
|
|
218
|
+
}
|
|
219
|
+
return crypto.createSecretKey(bytes);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Loads the key used to verify signatures.
|
|
224
|
+
*
|
|
225
|
+
* Strings and Buffers are always parsed as asymmetric keys (a private key is
|
|
226
|
+
* reduced to its public half). To verify HMAC signatures, pass the result of
|
|
227
|
+
* {@link loadSharedSecret} instead.
|
|
228
|
+
*
|
|
229
|
+
* @param {string|Buffer|Object|crypto.KeyObject} key - PEM text, a createPublicKey() input, or a KeyObject
|
|
230
|
+
* @returns {crypto.KeyObject} - A public or secret KeyObject
|
|
231
|
+
*/
|
|
232
|
+
export function loadVerificationKey(key) {
|
|
233
|
+
if (key instanceof crypto.KeyObject) {
|
|
234
|
+
return key.type === "private" ? crypto.createPublicKey(key) : key;
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
return crypto.createPublicKey(key);
|
|
238
|
+
} catch (err) {
|
|
239
|
+
throw new Error(`Unable to load the verification key: ${err.message}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function loadSigningKey(privateKey, alg, spec) {
|
|
244
|
+
let key;
|
|
245
|
+
if (spec.keyType === "secret") {
|
|
246
|
+
key = loadSharedSecret(privateKey);
|
|
247
|
+
} else if (privateKey instanceof crypto.KeyObject) {
|
|
248
|
+
key = privateKey;
|
|
249
|
+
} else {
|
|
250
|
+
try {
|
|
251
|
+
key = crypto.createPrivateKey(privateKey);
|
|
252
|
+
} catch (err) {
|
|
253
|
+
throw new Error(`Unable to load the signing key: ${err.message}`);
|
|
65
254
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
255
|
+
}
|
|
256
|
+
assertKeyMatchesAlgorithm(key, alg, spec);
|
|
257
|
+
if (spec.keyType !== "secret" && key.type !== "private") {
|
|
258
|
+
throw new Error(
|
|
259
|
+
`Signing with ${alg} needs a private key, but a ${key.type} key was supplied.`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
return key;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Generates a PEM key pair suitable for the given JSF algorithm.
|
|
267
|
+
*
|
|
268
|
+
* @param {string} algorithm - JSF algorithm identifier
|
|
269
|
+
* @returns {{ publicKey: string, privateKey: string }} - PEM encoded SPKI public key and PKCS#8 private key
|
|
270
|
+
*/
|
|
271
|
+
export function generateSigningKeyPair(algorithm) {
|
|
272
|
+
const spec = getAlgorithm(algorithm);
|
|
273
|
+
const encoding = {
|
|
274
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
275
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
276
|
+
};
|
|
277
|
+
switch (spec.keyType) {
|
|
278
|
+
case "secret":
|
|
279
|
+
throw new Error(
|
|
280
|
+
`${algorithm} uses a shared secret, so there is no key pair to generate.`,
|
|
281
|
+
);
|
|
282
|
+
case "rsa":
|
|
283
|
+
case "rsa-pss":
|
|
284
|
+
return crypto.generateKeyPairSync("rsa", {
|
|
285
|
+
modulusLength: 4096,
|
|
286
|
+
...encoding,
|
|
287
|
+
});
|
|
288
|
+
case "ec":
|
|
289
|
+
return crypto.generateKeyPairSync("ec", {
|
|
290
|
+
namedCurve: spec.curve,
|
|
291
|
+
...encoding,
|
|
292
|
+
});
|
|
293
|
+
default:
|
|
294
|
+
return crypto.generateKeyPairSync(spec.keyType, encoding);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function signatureOptions(key, spec) {
|
|
299
|
+
const options = { key };
|
|
300
|
+
if (spec.keyType === "rsa-pss") {
|
|
301
|
+
options.padding = crypto.constants.RSA_PKCS1_PSS_PADDING;
|
|
302
|
+
// JWA fixes the PSS salt length to the digest length.
|
|
303
|
+
options.saltLength = crypto.constants.RSA_PSS_SALTLEN_DIGEST;
|
|
304
|
+
} else if (spec.keyType === "ec") {
|
|
78
305
|
// Standard JWA format requires IEEE P1363 (R || S) instead of ASN.1 DER
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
306
|
+
options.dsaEncoding = "ieee-p1363";
|
|
307
|
+
}
|
|
308
|
+
return options;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function computeSignature(canonicalData, key, spec) {
|
|
312
|
+
const data = Buffer.from(canonicalData, "utf8");
|
|
313
|
+
if (spec.keyType === "secret") {
|
|
314
|
+
return crypto.createHmac(spec.hash, key).update(data).digest();
|
|
85
315
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
316
|
+
return crypto.sign(spec.hash, data, signatureOptions(key, spec));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function signatureMatches(canonicalData, key, spec, signature) {
|
|
320
|
+
const data = Buffer.from(canonicalData, "utf8");
|
|
321
|
+
if (spec.keyType === "secret") {
|
|
322
|
+
const expected = crypto.createHmac(spec.hash, key).update(data).digest();
|
|
323
|
+
return (
|
|
324
|
+
signature.length === expected.length &&
|
|
325
|
+
crypto.timingSafeEqual(signature, expected)
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
return crypto.verify(spec.hash, data, signatureOptions(key, spec), signature);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// KeyObject.equals() on keys of different types leaves an OpenSSL error queued,
|
|
332
|
+
// which the next unrelated key operation in the process then throws. Compare
|
|
333
|
+
// the key types first so a hostile embedded key cannot disturb later checks.
|
|
334
|
+
function isSameKey(a, b) {
|
|
335
|
+
return (
|
|
336
|
+
a.type === b.type &&
|
|
337
|
+
a.asymmetricKeyType === b.asymmetricKeyType &&
|
|
338
|
+
a.asymmetricKeyDetails?.namedCurve === b.asymmetricKeyDetails?.namedCurve &&
|
|
339
|
+
a.equals(b)
|
|
90
340
|
);
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function publicKeyFromJwk(jwk) {
|
|
344
|
+
if (!isPlainObject(jwk)) {
|
|
345
|
+
throw new Error("The signature publicKey must be a JWK object.");
|
|
346
|
+
}
|
|
347
|
+
return crypto.createPublicKey({ key: jwk, format: "jwk" });
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function embeddablePublicKey(publicKeyJwk, signingKey, alg) {
|
|
351
|
+
if (!publicKeyJwk) {
|
|
352
|
+
return undefined;
|
|
353
|
+
}
|
|
354
|
+
if (signingKey.type === "secret") {
|
|
355
|
+
throw new Error(
|
|
356
|
+
`${alg} signatures use a shared secret, so a public key cannot be embedded.`,
|
|
357
|
+
);
|
|
94
358
|
}
|
|
95
|
-
|
|
96
|
-
|
|
359
|
+
const derived = crypto.createPublicKey(signingKey);
|
|
360
|
+
if (!isSameKey(publicKeyFromJwk(publicKeyJwk), derived)) {
|
|
361
|
+
throw new Error(
|
|
362
|
+
"The public key to embed in the signature does not belong to the signing key.",
|
|
363
|
+
);
|
|
97
364
|
}
|
|
98
|
-
|
|
365
|
+
// Export from the derived key so that only public members are ever embedded.
|
|
366
|
+
return derived.export({ format: "jwk" });
|
|
99
367
|
}
|
|
100
368
|
|
|
101
369
|
/**
|
|
102
|
-
*
|
|
370
|
+
* Splits a JSF signature property into its signature objects.
|
|
371
|
+
*
|
|
372
|
+
* @returns {{ form: "single"|"signers"|"chain", entries: Object[], wrapper: Object }}
|
|
103
373
|
*/
|
|
104
|
-
function
|
|
105
|
-
if (!
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
374
|
+
function readSignatureEntries(signature) {
|
|
375
|
+
if (!isPlainObject(signature)) {
|
|
376
|
+
throw new Error("The signature must be a JSON object.");
|
|
377
|
+
}
|
|
378
|
+
const hasSigners = Object.hasOwn(signature, "signers");
|
|
379
|
+
const hasChain = Object.hasOwn(signature, "chain");
|
|
380
|
+
if (hasSigners && hasChain) {
|
|
381
|
+
throw new Error("A signature cannot contain both signers and chain.");
|
|
382
|
+
}
|
|
383
|
+
if (!hasSigners && !hasChain) {
|
|
384
|
+
return { form: "single", entries: [signature], wrapper: {} };
|
|
385
|
+
}
|
|
386
|
+
const form = hasSigners ? "signers" : "chain";
|
|
387
|
+
const { [form]: entries, ...wrapper } = signature;
|
|
388
|
+
if (
|
|
389
|
+
!Array.isArray(entries) ||
|
|
390
|
+
!entries.length ||
|
|
391
|
+
!entries.every(isPlainObject)
|
|
392
|
+
) {
|
|
393
|
+
throw new Error(`The ${form} array must contain signature objects.`);
|
|
394
|
+
}
|
|
395
|
+
return { form, entries, wrapper };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function withoutValue(entry) {
|
|
399
|
+
const { value: _value, ...rest } = entry;
|
|
400
|
+
return rest;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Builds the object whose canonical form is signed for entry `index`.
|
|
405
|
+
*
|
|
406
|
+
* Following JSF, only the `value` of the entry being processed is removed.
|
|
407
|
+
* Other `signers` are removed entirely, while a `chain` entry keeps every
|
|
408
|
+
* earlier entry (including its value) and drops the later ones.
|
|
409
|
+
*/
|
|
410
|
+
function jsfSignedView(content, form, entries, wrapper, index) {
|
|
411
|
+
if (form === "single") {
|
|
412
|
+
return { ...content, signature: withoutValue(entries[index]) };
|
|
413
|
+
}
|
|
414
|
+
const kept =
|
|
415
|
+
form === "signers"
|
|
416
|
+
? [withoutValue(entries[index])]
|
|
417
|
+
: [...entries.slice(0, index), withoutValue(entries[index])];
|
|
418
|
+
return { ...content, signature: { ...wrapper, [form]: kept } };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function candidateViews(content, parsed, index) {
|
|
422
|
+
const views = [
|
|
423
|
+
jsfSignedView(content, parsed.form, parsed.entries, parsed.wrapper, index),
|
|
424
|
+
];
|
|
425
|
+
// Appending a signers or chain entry to a single signature moves that
|
|
426
|
+
// signature into the new array. It was signed in the single signature form,
|
|
427
|
+
// which asserts the same thing as a signers entry at any position, and as a
|
|
428
|
+
// chain entry at position 0, where nothing precedes it.
|
|
429
|
+
if (
|
|
430
|
+
(parsed.form === "signers" || (parsed.form === "chain" && index === 0)) &&
|
|
431
|
+
!Object.keys(parsed.wrapper).length
|
|
432
|
+
) {
|
|
433
|
+
views.push(jsfSignedView(content, "single", parsed.entries, {}, index));
|
|
434
|
+
}
|
|
435
|
+
return views;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function decodeSignatureValue(value) {
|
|
439
|
+
if (typeof value !== "string" || !value.length) {
|
|
440
|
+
throw new Error("The signature value is missing.");
|
|
441
|
+
}
|
|
442
|
+
const signature = Buffer.from(value, "base64url");
|
|
443
|
+
if (signature.toString("base64url") !== value) {
|
|
444
|
+
throw new Error("The signature value is not canonical base64url.");
|
|
445
|
+
}
|
|
446
|
+
return signature;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function verifyEntry(content, parsed, index, key) {
|
|
450
|
+
const entry = parsed.entries[index];
|
|
451
|
+
const spec = getAlgorithm(entry.algorithm);
|
|
452
|
+
const signature = decodeSignatureValue(entry.value);
|
|
453
|
+
if (
|
|
454
|
+
Object.hasOwn(entry, "excludes") ||
|
|
455
|
+
Object.hasOwn(parsed.wrapper, "excludes")
|
|
456
|
+
) {
|
|
457
|
+
throw new Error("Signatures that use JSF excludes are not supported.");
|
|
458
|
+
}
|
|
459
|
+
assertKeyMatchesAlgorithm(key, entry.algorithm, spec);
|
|
460
|
+
if (entry.publicKey !== undefined) {
|
|
461
|
+
if (key.type === "secret") {
|
|
462
|
+
throw new Error("HMAC signatures must not embed a public key.");
|
|
116
463
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
throw new Error("Cannot mix signature chains and multi-signers.");
|
|
122
|
-
} else {
|
|
123
|
-
target.signature = { signers: [target.signature, sigBlock] };
|
|
464
|
+
if (!isSameKey(publicKeyFromJwk(entry.publicKey), key)) {
|
|
465
|
+
throw new Error(
|
|
466
|
+
"The embedded publicKey does not match the verification key.",
|
|
467
|
+
);
|
|
124
468
|
}
|
|
125
|
-
} else {
|
|
126
|
-
target.signature = sigBlock;
|
|
127
469
|
}
|
|
470
|
+
return candidateViews(content, parsed, index).some((view) =>
|
|
471
|
+
signatureMatches(canonicalize(view), key, spec, signature),
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function mismatchReason(entry) {
|
|
476
|
+
return `The ${entry.algorithm} signature does not verify with this key: the signed content changed, or a different key made it.`;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function signatureLayout(target, mode) {
|
|
480
|
+
const existing = target.signature;
|
|
481
|
+
if (mode === "replace" || existing === undefined) {
|
|
482
|
+
return {
|
|
483
|
+
form: mode === "replace" ? "single" : mode,
|
|
484
|
+
entries: [],
|
|
485
|
+
wrapper: {},
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
const parsed = readSignatureEntries(existing);
|
|
489
|
+
if (parsed.form === "single") {
|
|
490
|
+
return { form: mode, entries: parsed.entries, wrapper: {} };
|
|
491
|
+
}
|
|
492
|
+
if (parsed.form !== mode) {
|
|
493
|
+
throw new Error("Cannot mix signature chains and multi-signers.");
|
|
494
|
+
}
|
|
495
|
+
return parsed;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function addSignature(target, key, spec, template, layout) {
|
|
499
|
+
const { signature: _existing, ...content } = target;
|
|
500
|
+
const entry = structuredClone(template);
|
|
501
|
+
const entries = [...layout.entries, entry];
|
|
502
|
+
const index = entries.length - 1;
|
|
503
|
+
const view = jsfSignedView(
|
|
504
|
+
content,
|
|
505
|
+
layout.form,
|
|
506
|
+
entries,
|
|
507
|
+
layout.wrapper,
|
|
508
|
+
index,
|
|
509
|
+
);
|
|
510
|
+
entry.value = computeSignature(canonicalize(view), key, spec).toString(
|
|
511
|
+
"base64url",
|
|
512
|
+
);
|
|
513
|
+
target.signature =
|
|
514
|
+
layout.form === "single"
|
|
515
|
+
? entry
|
|
516
|
+
: { ...layout.wrapper, [layout.form]: entries };
|
|
128
517
|
}
|
|
129
518
|
|
|
130
519
|
/**
|
|
131
|
-
*
|
|
520
|
+
* Applies JSF signatures to the BOM and, optionally, its components, services,
|
|
521
|
+
* and annotations.
|
|
522
|
+
*
|
|
523
|
+
* Nested elements are signed by default. When a `signers` or `chain` signature
|
|
524
|
+
* is appended to a BOM that already has a root signature, nested elements are
|
|
525
|
+
* skipped by default, because changing them would invalidate the existing root
|
|
526
|
+
* signature; explicitly requesting nested signing in that case is an error.
|
|
132
527
|
*
|
|
133
528
|
* @param {Object} bomJson - CycloneDX BOM Object
|
|
134
|
-
* @param {Object} options - Signing options
|
|
529
|
+
* @param {Object} options - Signing options
|
|
530
|
+
* @param {string|Buffer|Object|crypto.KeyObject} options.privateKey - Private key (PEM or KeyObject), or the shared secret for HS* algorithms
|
|
531
|
+
* @param {string} [options.algorithm] - JSF algorithm identifier (default RS512). It must match the key type.
|
|
532
|
+
* @param {Object} [options.publicKeyJwk] - JWK of the signing key's public key to embed
|
|
533
|
+
* @param {string} [options.keyId] - Key ID to embed
|
|
534
|
+
* @param {string} [options.mode] - 'replace' (default), 'signers', or 'chain'
|
|
535
|
+
* @param {boolean} [options.signComponents] - Sign each component
|
|
536
|
+
* @param {boolean} [options.signServices] - Sign each service
|
|
537
|
+
* @param {boolean} [options.signAnnotations] - Sign each annotation
|
|
135
538
|
* @returns {Object} - Signed BOM Object
|
|
136
539
|
*/
|
|
137
540
|
export function signBom(bomJson, options = {}) {
|
|
@@ -140,173 +543,201 @@ export function signBom(bomJson, options = {}) {
|
|
|
140
543
|
algorithm = "RS512",
|
|
141
544
|
publicKeyJwk = null,
|
|
142
545
|
keyId = null,
|
|
143
|
-
mode = "replace",
|
|
144
|
-
signComponents = true,
|
|
145
|
-
signServices = true,
|
|
146
|
-
signAnnotations = true,
|
|
546
|
+
mode = "replace",
|
|
147
547
|
} = options;
|
|
148
548
|
|
|
149
549
|
if (!privateKey) {
|
|
150
550
|
throw new Error("privateKey is required for signing");
|
|
151
551
|
}
|
|
152
|
-
if (
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
createSignatureBlock(comp, privateKey, algorithm, publicKeyJwk, keyId),
|
|
157
|
-
mode,
|
|
158
|
-
);
|
|
159
|
-
}
|
|
552
|
+
if (!SIGNATURE_MODES.includes(mode)) {
|
|
553
|
+
throw new Error(
|
|
554
|
+
`Unsupported signature mode: ${mode}. Use one of ${SIGNATURE_MODES.join(", ")}.`,
|
|
555
|
+
);
|
|
160
556
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
);
|
|
168
|
-
}
|
|
557
|
+
const spec = getAlgorithm(algorithm);
|
|
558
|
+
const signingKey = loadSigningKey(privateKey, algorithm, spec);
|
|
559
|
+
const template = { algorithm };
|
|
560
|
+
const publicKey = embeddablePublicKey(publicKeyJwk, signingKey, algorithm);
|
|
561
|
+
if (publicKey) {
|
|
562
|
+
template.publicKey = publicKey;
|
|
169
563
|
}
|
|
170
|
-
if (
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
564
|
+
if (keyId !== null && keyId !== undefined && keyId !== "") {
|
|
565
|
+
template.keyId = String(keyId);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const appending = mode !== "replace" && bomJson.signature !== undefined;
|
|
569
|
+
const requested = {
|
|
570
|
+
components: options.signComponents,
|
|
571
|
+
services: options.signServices,
|
|
572
|
+
annotations: options.signAnnotations,
|
|
573
|
+
};
|
|
574
|
+
// Work out every signature layout before signing anything, so a rejected
|
|
575
|
+
// request leaves the BOM untouched.
|
|
576
|
+
const plan = [];
|
|
577
|
+
for (const field of NESTED_TARGETS) {
|
|
578
|
+
const items = Array.isArray(bomJson[field]) ? bomJson[field] : [];
|
|
579
|
+
const enabled =
|
|
580
|
+
requested[field] === undefined ? !appending : Boolean(requested[field]);
|
|
581
|
+
if (!enabled || !items.length) {
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
if (appending) {
|
|
585
|
+
throw new Error(
|
|
586
|
+
`Cannot sign ${field} while appending a ${mode} signature, because that changes content covered by the existing root signature. Sign the root only (for example, --no-sign-${field}).`,
|
|
176
587
|
);
|
|
177
588
|
}
|
|
589
|
+
for (const item of items) {
|
|
590
|
+
if (isPlainObject(item)) {
|
|
591
|
+
plan.push([item, signatureLayout(item, mode)]);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
178
594
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
);
|
|
595
|
+
const rootLayout = signatureLayout(bomJson, mode);
|
|
596
|
+
for (const [item, layout] of plan) {
|
|
597
|
+
addSignature(item, signingKey, spec, template, layout);
|
|
598
|
+
}
|
|
599
|
+
addSignature(bomJson, signingKey, spec, template, rootLayout);
|
|
184
600
|
return bomJson;
|
|
185
601
|
}
|
|
186
602
|
|
|
187
603
|
/**
|
|
188
|
-
*
|
|
604
|
+
* Verifies the signature of a single node (e.g., BOM root, Component, Service,
|
|
605
|
+
* Annotation). Handles single signatures, multi-signatures (signers), and
|
|
606
|
+
* signature chains, and returns the first signature object that matches the key.
|
|
189
607
|
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
* @returns {boolean|Object} - Signature block if signature is valid. False otherwise.
|
|
195
|
-
*/
|
|
196
|
-
function verifySignatureBlock(payload, publicKey, sigBlock) {
|
|
197
|
-
const { signature, ...dataToVerify } = payload;
|
|
198
|
-
const canonicalData = canonicalize(dataToVerify);
|
|
199
|
-
|
|
200
|
-
const { algorithm: alg, value } = sigBlock;
|
|
201
|
-
|
|
202
|
-
if (alg.startsWith("HS")) {
|
|
203
|
-
const hash = alg.replace("HS", "sha");
|
|
204
|
-
const expected = crypto
|
|
205
|
-
.createHmac(hash, publicKey)
|
|
206
|
-
.update(canonicalData, "utf8")
|
|
207
|
-
.digest("base64url");
|
|
208
|
-
const isValid = expected === value;
|
|
209
|
-
return isValid ? sigBlock : false;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
const verifyOptions = { key: publicKey };
|
|
213
|
-
let hashAlg;
|
|
214
|
-
|
|
215
|
-
if (alg.startsWith("RS")) {
|
|
216
|
-
hashAlg = alg.replace("RS", "SHA");
|
|
217
|
-
} else if (alg.startsWith("PS")) {
|
|
218
|
-
hashAlg = alg.replace("PS", "SHA");
|
|
219
|
-
verifyOptions.padding = crypto.constants.RSA_PKCS1_PSS_PADDING;
|
|
220
|
-
verifyOptions.saltLength = crypto.constants.RSA_PSS_SALTLEN_AUTO;
|
|
221
|
-
} else if (alg.startsWith("ES")) {
|
|
222
|
-
hashAlg = alg.replace("ES", "SHA");
|
|
223
|
-
verifyOptions.dsaEncoding = "ieee-p1363";
|
|
224
|
-
} else if (alg === "Ed25519" || alg === "Ed448") {
|
|
225
|
-
hashAlg = null;
|
|
226
|
-
} else {
|
|
227
|
-
console.warn(`${alg} is unknown.`);
|
|
228
|
-
return false;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
const isValid = crypto.verify(
|
|
232
|
-
hashAlg,
|
|
233
|
-
Buffer.from(canonicalData, "utf8"),
|
|
234
|
-
verifyOptions,
|
|
235
|
-
Buffer.from(value, "base64url"),
|
|
236
|
-
);
|
|
237
|
-
return isValid ? sigBlock : false;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* Verifies the integrity of a specific element node (e.g., BOM root, Component, Service, Annotation).
|
|
242
|
-
* Resolves standard JSF signatures, multisignature (signers), and chains.
|
|
608
|
+
* A signature only matches when its declared algorithm fits the key type, its
|
|
609
|
+
* embedded publicKey (if any) is the verification key, and its value covers the
|
|
610
|
+
* node content plus the signature metadata as JSF defines.
|
|
243
611
|
*
|
|
244
612
|
* @param {Object} node - The BOM or granular object to verify
|
|
245
|
-
* @param {string|crypto.KeyObject} publicKey -
|
|
246
|
-
* @
|
|
613
|
+
* @param {string|Buffer|Object|crypto.KeyObject} publicKey - Verification key. Strings and Buffers are parsed as public keys; use loadSharedSecret() for HMAC.
|
|
614
|
+
* @param {Object} [options]
|
|
615
|
+
* @param {string[]} [options.reasons] - When no signature matches, receives the reason each entry was rejected
|
|
616
|
+
* @returns {boolean|Object} - Matching signature object if valid. False otherwise.
|
|
247
617
|
*/
|
|
248
|
-
export function verifyNode(node, publicKey) {
|
|
249
|
-
if (!node
|
|
618
|
+
export function verifyNode(node, publicKey, options = {}) {
|
|
619
|
+
if (!isPlainObject(node) || node.signature === undefined) {
|
|
250
620
|
return false;
|
|
251
621
|
}
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
}
|
|
622
|
+
const key = loadVerificationKey(publicKey);
|
|
623
|
+
const reasons = Array.isArray(options.reasons) ? options.reasons : undefined;
|
|
624
|
+
let parsed;
|
|
625
|
+
try {
|
|
626
|
+
parsed = readSignatureEntries(node.signature);
|
|
627
|
+
} catch (err) {
|
|
628
|
+
reasons?.push(err.message);
|
|
260
629
|
return false;
|
|
261
630
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
631
|
+
const { signature: _signature, ...content } = node;
|
|
632
|
+
const rejected = [];
|
|
633
|
+
for (let index = 0; index < parsed.entries.length; index++) {
|
|
634
|
+
const label = parsed.form === "single" ? "" : `${parsed.form}[${index}]: `;
|
|
635
|
+
try {
|
|
636
|
+
if (verifyEntry(content, parsed, index, key)) {
|
|
637
|
+
return parsed.entries[index];
|
|
267
638
|
}
|
|
639
|
+
rejected.push(`${label}${mismatchReason(parsed.entries[index])}`);
|
|
640
|
+
} catch (err) {
|
|
641
|
+
rejected.push(`${label}${err.message}`);
|
|
268
642
|
}
|
|
269
|
-
return false;
|
|
270
643
|
}
|
|
271
|
-
|
|
644
|
+
reasons?.push(...rejected);
|
|
645
|
+
return false;
|
|
272
646
|
}
|
|
273
647
|
|
|
274
648
|
/**
|
|
275
|
-
* Verifies
|
|
276
|
-
* Returns
|
|
649
|
+
* Verifies a BOM's top-level signature, as well as nested components, services,
|
|
650
|
+
* and annotations. Returns the root match only if the root signature is valid
|
|
651
|
+
* AND every signed nested element is valid for the same key.
|
|
277
652
|
*
|
|
278
653
|
* @param {Object} bom - CycloneDX BOM Object
|
|
279
|
-
* @param {string|crypto.KeyObject} publicKey -
|
|
280
|
-
* @
|
|
654
|
+
* @param {string|Buffer|Object|crypto.KeyObject} publicKey - Verification key (see verifyNode)
|
|
655
|
+
* @param {Object} [options] - Same options as verifyNode
|
|
656
|
+
* @returns {boolean|Object} - Signature object if valid. False otherwise.
|
|
281
657
|
*/
|
|
282
|
-
export function verifyBom(bom, publicKey) {
|
|
658
|
+
export function verifyBom(bom, publicKey, options = {}) {
|
|
283
659
|
if (!bom?.signature) {
|
|
284
660
|
return false;
|
|
285
661
|
}
|
|
286
|
-
const
|
|
662
|
+
const key = loadVerificationKey(publicKey);
|
|
663
|
+
const reasons = Array.isArray(options.reasons) ? options.reasons : undefined;
|
|
664
|
+
const rootMatch = verifyNode(bom, key, options);
|
|
287
665
|
if (!rootMatch) {
|
|
288
666
|
return false;
|
|
289
667
|
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
return false;
|
|
294
|
-
}
|
|
668
|
+
for (const field of NESTED_TARGETS) {
|
|
669
|
+
if (!Array.isArray(bom[field])) {
|
|
670
|
+
continue;
|
|
295
671
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
if (svc.signature && !verifyNode(svc, publicKey)) {
|
|
300
|
-
return false;
|
|
672
|
+
for (const [index, item] of bom[field].entries()) {
|
|
673
|
+
if (!item?.signature) {
|
|
674
|
+
continue;
|
|
301
675
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
676
|
+
const nestedReasons = [];
|
|
677
|
+
if (!verifyNode(item, key, { ...options, reasons: nestedReasons })) {
|
|
678
|
+
const name = displayValue(
|
|
679
|
+
item["bom-ref"] || item.name || item.subject || index,
|
|
680
|
+
);
|
|
681
|
+
for (const reason of nestedReasons) {
|
|
682
|
+
reasons?.push(`${field}[${index}] '${name}': ${reason}`);
|
|
683
|
+
}
|
|
307
684
|
return false;
|
|
308
685
|
}
|
|
309
686
|
}
|
|
310
687
|
}
|
|
311
688
|
return rootMatch;
|
|
312
689
|
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Checks every signature entry of a node against a set of trusted keys. An
|
|
693
|
+
* entry is verified when one of the keys verifies that entry.
|
|
694
|
+
*
|
|
695
|
+
* `cdx-sign` uses this before appending to a signature chain, because the new
|
|
696
|
+
* chain entry vouches for every earlier entry.
|
|
697
|
+
*
|
|
698
|
+
* @param {Object} node - The BOM or granular object whose signature is checked
|
|
699
|
+
* @param {Array<string|Buffer|Object|crypto.KeyObject>} [keys] - Trusted verification keys (see verifyNode)
|
|
700
|
+
* @returns {Array<{ index: number, algorithm: any, keyId: any, verified: boolean, reasons: string[] }>} - One result per signature entry; empty for an unsigned node
|
|
701
|
+
*/
|
|
702
|
+
export function checkSignatureEntries(node, keys = []) {
|
|
703
|
+
if (!isPlainObject(node) || node.signature === undefined) {
|
|
704
|
+
return [];
|
|
705
|
+
}
|
|
706
|
+
const trustedKeys = keys.map((key) => loadVerificationKey(key));
|
|
707
|
+
let parsed;
|
|
708
|
+
try {
|
|
709
|
+
parsed = readSignatureEntries(node.signature);
|
|
710
|
+
} catch (err) {
|
|
711
|
+
return [
|
|
712
|
+
{
|
|
713
|
+
index: 0,
|
|
714
|
+
algorithm: undefined,
|
|
715
|
+
keyId: undefined,
|
|
716
|
+
verified: false,
|
|
717
|
+
reasons: [err.message],
|
|
718
|
+
},
|
|
719
|
+
];
|
|
720
|
+
}
|
|
721
|
+
const { signature: _signature, ...content } = node;
|
|
722
|
+
return parsed.entries.map((entry, index) => {
|
|
723
|
+
const reasons = [];
|
|
724
|
+
const verified = trustedKeys.some((key) => {
|
|
725
|
+
try {
|
|
726
|
+
if (verifyEntry(content, parsed, index, key)) {
|
|
727
|
+
return true;
|
|
728
|
+
}
|
|
729
|
+
reasons.push(mismatchReason(entry));
|
|
730
|
+
} catch (err) {
|
|
731
|
+
reasons.push(err.message);
|
|
732
|
+
}
|
|
733
|
+
return false;
|
|
734
|
+
});
|
|
735
|
+
return {
|
|
736
|
+
index,
|
|
737
|
+
algorithm: entry.algorithm,
|
|
738
|
+
keyId: entry.keyId,
|
|
739
|
+
verified,
|
|
740
|
+
reasons: verified ? [] : [...new Set(reasons)],
|
|
741
|
+
};
|
|
742
|
+
});
|
|
743
|
+
}
|