@cdxgen/cdxgen 13.2.0 → 13.4.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 +33 -14
- package/bin/cdxgen.js +310 -138
- package/bin/evinse.js +22 -0
- package/bin/repl.js +86 -0
- package/bin/sign.js +119 -15
- package/bin/tracebom.js +69 -5
- package/bin/validate.js +107 -19
- package/bin/verify.js +172 -63
- package/data/helpers/init.gradle +46 -21
- package/data/remediations.json +85 -0
- package/data/rules/dependency-sources.yaml +67 -0
- package/data/rules/scala-evidence.yaml +115 -0
- package/lib/audit/index.js +38 -6
- package/lib/cli/bomAssembly.js +105 -15
- package/lib/cli/cliOptions.js +93 -1
- package/lib/cli/index.js +156 -58
- package/lib/cli/jsBom.js +99 -53
- package/lib/cli/jvmBom.js +708 -147
- package/lib/cli/managedBom.js +415 -224
- package/lib/cli/nativeBom.js +264 -44
- package/lib/core/activity.js +12 -0
- package/lib/core/env.js +22 -15
- package/lib/core/fs.js +176 -7
- package/lib/core/httpClient.js +159 -24
- package/lib/core/metadataProperties.js +97 -0
- package/lib/core/pluginRun.js +184 -0
- package/lib/core/processTree.js +100 -0
- package/lib/core/projectConfig.js +31 -4
- package/lib/core/runState.js +39 -0
- package/lib/core/toml.js +72 -0
- package/lib/ecosystems/asarutils.js +4 -2
- package/lib/ecosystems/bunutils.js +274 -108
- package/lib/ecosystems/cargoCache.js +231 -8
- package/lib/ecosystems/caxa.js +434 -2
- package/lib/ecosystems/cmakeResolver.js +76 -21
- package/lib/ecosystems/cppBuildContext.js +631 -0
- package/lib/ecosystems/cppEvidence.js +399 -51
- package/lib/ecosystems/cppIncludeResolver.js +294 -0
- package/lib/ecosystems/denoutils.js +93 -66
- package/lib/ecosystems/ecosystems.js +1983 -793
- package/lib/ecosystems/golem.js +28 -12
- package/lib/ecosystems/gradleutils.js +356 -37
- package/lib/ecosystems/jsEvidence.js +104 -44
- package/lib/ecosystems/kosi.js +31 -7
- package/lib/ecosystems/npmutils.js +174 -9
- package/lib/ecosystems/parsers-dotnet.js +616 -159
- package/lib/ecosystems/parsers-elm.js +46 -28
- package/lib/ecosystems/parsers-gleam.js +3 -4
- package/lib/ecosystems/parsers-go.js +371 -70
- package/lib/ecosystems/parsers-js.js +263 -76
- package/lib/ecosystems/parsers-julia.js +2 -3
- package/lib/ecosystems/parsers-jvm.js +204 -12
- package/lib/ecosystems/parsers-misc.js +929 -216
- package/lib/ecosystems/parsers-python.js +167 -24
- package/lib/ecosystems/parsers-rust.js +28 -22
- package/lib/ecosystems/parsers-terraform.js +1573 -19
- package/lib/ecosystems/pythonutils.js +102 -6
- package/lib/ecosystems/remote/huggingface.js +3 -0
- package/lib/ecosystems/rubyutils.js +1492 -182
- package/lib/ecosystems/rusi.js +54 -10
- package/lib/ecosystems/sbtutils.js +243 -187
- package/lib/ecosystems/scalacliutils.js +342 -0
- package/lib/ecosystems/scalasem.js +2618 -0
- package/lib/ecosystems/terraformRegistry.js +288 -0
- package/lib/ecosystems/utils.js +2 -0
- package/lib/ecosystems/vendoredCode.js +156 -0
- package/lib/evinser/evinser.js +1008 -191
- package/lib/evinser/swiftsem.js +1914 -302
- package/lib/helpers/agenticToolutils.js +2 -2
- package/lib/helpers/bomSigner.js +1101 -222
- package/lib/inventory/aiCollector.js +4 -1
- package/lib/inventory/analyzer.js +110 -127
- package/lib/inventory/atomUtils.js +703 -43
- package/lib/inventory/auditCategories.js +7 -0
- package/lib/inventory/buildToolRateLimit.js +144 -0
- package/lib/inventory/cacheCommands.js +2 -1
- package/lib/inventory/cdxrs.js +103 -12
- package/lib/inventory/cmakeBuildDirs.js +254 -0
- package/lib/inventory/deps.js +501 -124
- package/lib/inventory/depsUtils.js +183 -11
- package/lib/inventory/display.js +4 -5
- package/lib/inventory/dosai.js +798 -57
- package/lib/inventory/dosaiParsers.js +310 -15
- package/lib/inventory/dynamic.js +90 -6
- package/lib/inventory/evidenceUtils.js +166 -113
- package/lib/inventory/fetchBatch.js +690 -55
- package/lib/inventory/fetchDiskCache.js +597 -0
- package/lib/inventory/forgeEnricher.js +14 -5
- package/lib/inventory/jvmLocalRepos.js +917 -0
- package/lib/inventory/mcpConfigParser.js +2 -3
- package/lib/inventory/parentSelection.js +146 -0
- package/lib/inventory/protobom.js +8 -1
- package/lib/inventory/purl.js +152 -3
- package/lib/inventory/rustFormulationParser.js +3 -4
- package/lib/inventory/sbomDocument.js +2 -70
- package/lib/inventory/scalaCoords.js +153 -0
- package/lib/inventory/source.js +45 -27
- package/lib/inventory/spdx.js +101 -0
- package/lib/inventory/traceRunner.js +295 -25
- package/lib/inventory/tracedCrypto.js +294 -0
- package/lib/managers/binary.js +115 -24
- package/lib/managers/docker.js +467 -46
- package/lib/managers/oci.js +63 -3
- package/lib/parsers/cmakeCache.js +69 -0
- package/lib/parsers/cmakeLists.js +466 -0
- package/lib/parsers/cmakePresets.js +400 -0
- package/lib/parsers/compileCommands.js +557 -0
- package/lib/parsers/largeJson.js +454 -0
- package/lib/parsers/npmrc.js +19 -1
- package/lib/parsers/strictJson.js +322 -0
- package/lib/parsers/terraformConfig.js +1073 -0
- package/lib/parsers/terraformSource.js +652 -0
- package/lib/server/server.js +23 -0
- package/lib/stages/postgen/introspection/markers.js +1 -1
- package/lib/stages/postgen/postgen.js +83 -16
- package/lib/stages/postgen/specVersionCompat.js +32 -0
- package/lib/validator/bomValidator.js +6 -16
- package/lib/validator/index.js +64 -11
- package/lib/validator/reporters/console.js +3 -0
- package/lib/validator/reporters/json.js +2 -0
- package/lib/validator/reporters/sarif.js +7 -0
- package/package.json +23 -23
- package/types/lib/audit/index.d.ts.map +1 -1
- 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 +24 -1
- package/types/lib/cli/jvmBom.d.ts.map +1 -1
- package/types/lib/cli/managedBom.d.ts +7 -4
- package/types/lib/cli/managedBom.d.ts.map +1 -1
- package/types/lib/cli/nativeBom.d.ts +3 -1
- package/types/lib/cli/nativeBom.d.ts.map +1 -1
- package/types/lib/core/activity.d.ts.map +1 -1
- package/types/lib/core/env.d.ts +9 -0
- 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/httpClient.d.ts +13 -8
- package/types/lib/core/httpClient.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/pluginRun.d.ts +97 -0
- package/types/lib/core/pluginRun.d.ts.map +1 -0
- package/types/lib/core/processTree.d.ts +36 -0
- package/types/lib/core/processTree.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/core/runState.d.ts +29 -0
- package/types/lib/core/runState.d.ts.map +1 -0
- package/types/lib/core/toml.d.ts +20 -0
- package/types/lib/core/toml.d.ts.map +1 -0
- package/types/lib/ecosystems/asarutils.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/cargoCache.d.ts +12 -0
- package/types/lib/ecosystems/cargoCache.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 +28 -3
- package/types/lib/ecosystems/cmakeResolver.d.ts.map +1 -1
- package/types/lib/ecosystems/cppBuildContext.d.ts +55 -0
- package/types/lib/ecosystems/cppBuildContext.d.ts.map +1 -0
- package/types/lib/ecosystems/cppEvidence.d.ts +17 -1
- package/types/lib/ecosystems/cppEvidence.d.ts.map +1 -1
- package/types/lib/ecosystems/cppIncludeResolver.d.ts +92 -0
- package/types/lib/ecosystems/cppIncludeResolver.d.ts.map +1 -0
- package/types/lib/ecosystems/denoutils.d.ts +9 -3
- package/types/lib/ecosystems/denoutils.d.ts.map +1 -1
- package/types/lib/ecosystems/ecosystems.d.ts +62 -13
- 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 +70 -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 +57 -0
- package/types/lib/ecosystems/npmutils.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-dotnet.d.ts +118 -4
- package/types/lib/ecosystems/parsers-dotnet.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-elm.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-gleam.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-go.d.ts +54 -2
- package/types/lib/ecosystems/parsers-go.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-js.d.ts +16 -5
- package/types/lib/ecosystems/parsers-js.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-julia.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-jvm.d.ts +46 -1
- package/types/lib/ecosystems/parsers-jvm.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-misc.d.ts +70 -4
- package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
- package/types/lib/ecosystems/parsers-python.d.ts +53 -3
- 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/parsers-terraform.d.ts +38 -21
- package/types/lib/ecosystems/parsers-terraform.d.ts.map +1 -1
- package/types/lib/ecosystems/pythonutils.d.ts +27 -2
- package/types/lib/ecosystems/pythonutils.d.ts.map +1 -1
- package/types/lib/ecosystems/remote/huggingface.d.ts.map +1 -1
- package/types/lib/ecosystems/rubyutils.d.ts +164 -3
- 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/ecosystems/sbtutils.d.ts +88 -2
- package/types/lib/ecosystems/sbtutils.d.ts.map +1 -1
- package/types/lib/ecosystems/scalacliutils.d.ts +56 -0
- package/types/lib/ecosystems/scalacliutils.d.ts.map +1 -0
- package/types/lib/ecosystems/scalasem.d.ts +311 -0
- package/types/lib/ecosystems/scalasem.d.ts.map +1 -0
- package/types/lib/ecosystems/terraformRegistry.d.ts +9 -0
- package/types/lib/ecosystems/terraformRegistry.d.ts.map +1 -0
- package/types/lib/ecosystems/utils.d.ts +2 -2
- package/types/lib/ecosystems/utils.d.ts.map +1 -1
- package/types/lib/ecosystems/vendoredCode.d.ts +32 -0
- package/types/lib/ecosystems/vendoredCode.d.ts.map +1 -0
- package/types/lib/evinser/evinser.d.ts +132 -15
- 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 +182 -13
- package/types/lib/helpers/bomSigner.d.ts.map +1 -1
- package/types/lib/inventory/aiCollector.d.ts.map +1 -1
- package/types/lib/inventory/analyzer.d.ts.map +1 -1
- package/types/lib/inventory/atomUtils.d.ts +219 -5
- package/types/lib/inventory/atomUtils.d.ts.map +1 -1
- package/types/lib/inventory/auditCategories.d.ts +3 -0
- package/types/lib/inventory/auditCategories.d.ts.map +1 -1
- package/types/lib/inventory/buildToolRateLimit.d.ts +38 -0
- package/types/lib/inventory/buildToolRateLimit.d.ts.map +1 -0
- package/types/lib/inventory/cacheCommands.d.ts +2 -1
- package/types/lib/inventory/cacheCommands.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/cmakeBuildDirs.d.ts +58 -0
- package/types/lib/inventory/cmakeBuildDirs.d.ts.map +1 -0
- package/types/lib/inventory/deps.d.ts +59 -1
- package/types/lib/inventory/deps.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 +121 -9
- package/types/lib/inventory/dosai.d.ts.map +1 -1
- package/types/lib/inventory/dosaiParsers.d.ts +53 -7
- package/types/lib/inventory/dosaiParsers.d.ts.map +1 -1
- package/types/lib/inventory/dynamic.d.ts +10 -0
- package/types/lib/inventory/dynamic.d.ts.map +1 -1
- package/types/lib/inventory/evidenceUtils.d.ts +19 -4
- package/types/lib/inventory/evidenceUtils.d.ts.map +1 -1
- package/types/lib/inventory/fetchBatch.d.ts +83 -14
- package/types/lib/inventory/fetchBatch.d.ts.map +1 -1
- package/types/lib/inventory/fetchDiskCache.d.ts +169 -0
- package/types/lib/inventory/fetchDiskCache.d.ts.map +1 -0
- package/types/lib/inventory/forgeEnricher.d.ts.map +1 -1
- package/types/lib/inventory/jvmLocalRepos.d.ts +178 -0
- package/types/lib/inventory/jvmLocalRepos.d.ts.map +1 -0
- package/types/lib/inventory/mcpConfigParser.d.ts.map +1 -1
- package/types/lib/inventory/parentSelection.d.ts +34 -0
- package/types/lib/inventory/parentSelection.d.ts.map +1 -0
- 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/rustFormulationParser.d.ts.map +1 -1
- package/types/lib/inventory/sbomDocument.d.ts.map +1 -1
- package/types/lib/inventory/scalaCoords.d.ts +80 -0
- package/types/lib/inventory/scalaCoords.d.ts.map +1 -0
- package/types/lib/inventory/source.d.ts.map +1 -1
- package/types/lib/inventory/spdx.d.ts +9 -0
- package/types/lib/inventory/spdx.d.ts.map +1 -1
- package/types/lib/inventory/traceRunner.d.ts +59 -1
- package/types/lib/inventory/traceRunner.d.ts.map +1 -1
- package/types/lib/inventory/tracedCrypto.d.ts +42 -0
- package/types/lib/inventory/tracedCrypto.d.ts.map +1 -0
- 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 +17 -1
- package/types/lib/managers/oci.d.ts.map +1 -1
- package/types/lib/parsers/cmakeCache.d.ts +16 -0
- package/types/lib/parsers/cmakeCache.d.ts.map +1 -1
- package/types/lib/parsers/cmakeLists.d.ts +77 -0
- package/types/lib/parsers/cmakeLists.d.ts.map +1 -0
- package/types/lib/parsers/cmakePresets.d.ts +78 -0
- package/types/lib/parsers/cmakePresets.d.ts.map +1 -0
- package/types/lib/parsers/compileCommands.d.ts +115 -0
- package/types/lib/parsers/compileCommands.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/parsers/strictJson.d.ts +46 -0
- package/types/lib/parsers/strictJson.d.ts.map +1 -0
- package/types/lib/parsers/terraformConfig.d.ts +70 -0
- package/types/lib/parsers/terraformConfig.d.ts.map +1 -0
- package/types/lib/parsers/terraformSource.d.ts +66 -0
- package/types/lib/parsers/terraformSource.d.ts.map +1 -0
- package/types/lib/server/server.d.ts.map +1 -1
- package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
- package/types/lib/stages/postgen/specVersionCompat.d.ts.map +1 -1
- package/types/lib/validator/bomValidator.d.ts.map +1 -1
- package/types/lib/validator/index.d.ts +18 -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/third-party/arborist/snapshots/calc-dep-flags.test.cjs +0 -462
package/lib/helpers/bomSigner.js
CHANGED
|
@@ -1,137 +1,844 @@
|
|
|
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
|
+
[
|
|
18
|
+
"ES256",
|
|
19
|
+
{
|
|
20
|
+
keyType: "ec",
|
|
21
|
+
hash: "sha256",
|
|
22
|
+
curve: "prime256v1",
|
|
23
|
+
order:
|
|
24
|
+
0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551n,
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
[
|
|
28
|
+
"ES384",
|
|
29
|
+
{
|
|
30
|
+
keyType: "ec",
|
|
31
|
+
hash: "sha384",
|
|
32
|
+
curve: "secp384r1",
|
|
33
|
+
order:
|
|
34
|
+
0xffffffffffffffffffffffffffffffffffffffffffffffffc7634d81f4372ddf581a0db248b0a77aecec196accc52973n,
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
[
|
|
38
|
+
"ES512",
|
|
39
|
+
{
|
|
40
|
+
keyType: "ec",
|
|
41
|
+
hash: "sha512",
|
|
42
|
+
curve: "secp521r1",
|
|
43
|
+
order:
|
|
44
|
+
0x01fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa51868783bf2f966b7fcc0148f709a5d03bb5c9b8899c47aebb6fb71e91386409n,
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
["Ed25519", { keyType: "ed25519", hash: null }],
|
|
48
|
+
["Ed448", { keyType: "ed448", hash: null }],
|
|
49
|
+
["HS256", { keyType: "secret", hash: "sha256" }],
|
|
50
|
+
["HS384", { keyType: "secret", hash: "sha384" }],
|
|
51
|
+
["HS512", { keyType: "secret", hash: "sha512" }],
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
const SIGNATURE_MODES = ["replace", "signers", "chain"];
|
|
55
|
+
|
|
56
|
+
const NESTED_TARGETS = ["components", "services", "annotations"];
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Largest number of entries accepted in a `signers` or `chain` signature.
|
|
60
|
+
* Every entry is checked against the whole signed object, so the bound keeps
|
|
61
|
+
* the cost of verifying a document proportional to its size.
|
|
62
|
+
*/
|
|
63
|
+
export const MAX_SIGNATURE_ENTRIES = 32;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Members the CycloneDX 1.4 to 1.7 and 2.0 schemas define at the root of a
|
|
67
|
+
* BOM. A root signature is only accepted on an object made of these members,
|
|
68
|
+
* so a signed component, service, or annotation cannot pass as a signed BOM.
|
|
69
|
+
*/
|
|
70
|
+
export const BOM_ROOT_MEMBERS = Object.freeze([
|
|
71
|
+
"$schema",
|
|
72
|
+
"bomFormat",
|
|
73
|
+
"specFormat",
|
|
74
|
+
"specVersion",
|
|
75
|
+
"serialNumber",
|
|
76
|
+
"version",
|
|
77
|
+
"metadata",
|
|
78
|
+
"components",
|
|
79
|
+
"services",
|
|
80
|
+
"externalReferences",
|
|
81
|
+
"dependencies",
|
|
82
|
+
"compositions",
|
|
83
|
+
"vulnerabilities",
|
|
84
|
+
"annotations",
|
|
85
|
+
"formulation",
|
|
86
|
+
"declarations",
|
|
87
|
+
"definitions",
|
|
88
|
+
"citations",
|
|
89
|
+
"properties",
|
|
90
|
+
"signature",
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
const BOM_ROOT_MEMBER_SET = new Set(BOM_ROOT_MEMBERS);
|
|
94
|
+
|
|
95
|
+
// Members that identify a BOM. Components, services, and annotations never hold
|
|
96
|
+
// them, so a nested object carrying one is refused for signing and verification.
|
|
97
|
+
const BOM_IDENTITY_MEMBERS = ["bomFormat", "specFormat", "specVersion"];
|
|
98
|
+
|
|
99
|
+
const MAX_LISTED_MEMBERS = 10;
|
|
100
|
+
|
|
101
|
+
// DER layouts tried when checking whether raw bytes are really a key.
|
|
102
|
+
const DER_KEY_LAYOUTS = [
|
|
103
|
+
[crypto.createPublicKey, "spki"],
|
|
104
|
+
[crypto.createPublicKey, "pkcs1"],
|
|
105
|
+
[crypto.createPrivateKey, "pkcs8"],
|
|
106
|
+
[crypto.createPrivateKey, "pkcs1"],
|
|
107
|
+
[crypto.createPrivateKey, "sec1"],
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Renders a value taken from a document for a message. JSON can make any field
|
|
112
|
+
* an object or array, and interpolating those directly can throw.
|
|
113
|
+
*
|
|
114
|
+
* @param {any} value - Value to render
|
|
115
|
+
* @returns {string} - Printable text
|
|
116
|
+
*/
|
|
117
|
+
export function displayValue(value) {
|
|
118
|
+
if (typeof value === "string") {
|
|
119
|
+
return value;
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
return JSON.stringify(value) ?? String(value);
|
|
123
|
+
} catch {
|
|
124
|
+
return `<${typeof value}>`;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isPlainObject(value) {
|
|
129
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function listMembers(names) {
|
|
133
|
+
const shown = names.slice(0, MAX_LISTED_MEMBERS).map(displayValue);
|
|
134
|
+
if (names.length > MAX_LISTED_MEMBERS) {
|
|
135
|
+
shown.push(`and ${names.length - MAX_LISTED_MEMBERS} more`);
|
|
136
|
+
}
|
|
137
|
+
return shown.join(", ");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// A BOM names its format with bomFormat (CycloneDX 1.x) or specFormat (2.0).
|
|
141
|
+
function hasBomIdentity(bom) {
|
|
142
|
+
return (
|
|
143
|
+
(bom.bomFormat === "CycloneDX" || bom.specFormat === "CycloneDX") &&
|
|
144
|
+
((typeof bom.specVersion === "string" && bom.specVersion !== "") ||
|
|
145
|
+
typeof bom.specVersion === "number")
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function unexpectedRootMembers(bom) {
|
|
150
|
+
return Object.keys(bom).filter((name) => !BOM_ROOT_MEMBER_SET.has(name));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function bomIdentityMembers(item) {
|
|
154
|
+
return BOM_IDENTITY_MEMBERS.filter((name) => Object.hasOwn(item, name));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Deterministic JSON canonicalizer following RFC 8785 (JCS), which JSF uses to
|
|
159
|
+
* produce the signed data. Values are serialized the way JSON.stringify would
|
|
160
|
+
* see them, so an in-memory object and its parsed JSON form canonicalize
|
|
161
|
+
* identically.
|
|
6
162
|
*
|
|
7
163
|
* @param {any} value - The JSON object/value to canonicalize
|
|
8
|
-
* @returns {string} - Canonicalized JSON string
|
|
164
|
+
* @returns {string|undefined} - Canonicalized JSON string, or undefined for values JSON omits
|
|
9
165
|
*/
|
|
10
166
|
function canonicalize(value) {
|
|
167
|
+
if (typeof value?.toJSON === "function") {
|
|
168
|
+
value = value.toJSON();
|
|
169
|
+
}
|
|
11
170
|
if (value === null || typeof value !== "object") {
|
|
12
171
|
return JSON.stringify(value);
|
|
13
172
|
}
|
|
14
173
|
if (Array.isArray(value)) {
|
|
15
|
-
return `[${value.map(canonicalize).join(",")}]`;
|
|
174
|
+
return `[${value.map((item) => canonicalize(item) ?? "null").join(",")}]`;
|
|
16
175
|
}
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
if (
|
|
21
|
-
|
|
176
|
+
const members = [];
|
|
177
|
+
for (const key of Object.keys(value).sort()) {
|
|
178
|
+
const member = canonicalize(value[key]);
|
|
179
|
+
if (member !== undefined) {
|
|
180
|
+
members.push(`${JSON.stringify(key)}:${member}`);
|
|
181
|
+
}
|
|
22
182
|
}
|
|
23
|
-
|
|
24
|
-
return str;
|
|
183
|
+
return `{${members.join(",")}}`;
|
|
25
184
|
}
|
|
26
185
|
|
|
27
186
|
/**
|
|
28
|
-
*
|
|
187
|
+
* Returns a function that produces the UTF-8 bytes of the canonical form of
|
|
188
|
+
* `{ ...content, signature }` for any signature value, canonicalizing the
|
|
189
|
+
* content members only once. Canonical members are ordered by name, so the
|
|
190
|
+
* `signature` member always sits between the members whose names sort before
|
|
191
|
+
* it and those that sort after it.
|
|
29
192
|
*
|
|
30
|
-
* @param {Object}
|
|
31
|
-
* @
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
193
|
+
* @param {Object} content - The signed object without its signature member
|
|
194
|
+
* @returns {function(Object): Buffer} - Signed bytes for the given signature member
|
|
195
|
+
*/
|
|
196
|
+
function signedViewSerializer(content) {
|
|
197
|
+
if (typeof content.toJSON === "function") {
|
|
198
|
+
return (signature) =>
|
|
199
|
+
Buffer.from(canonicalize({ ...content, signature }), "utf8");
|
|
200
|
+
}
|
|
201
|
+
const before = [];
|
|
202
|
+
const after = [];
|
|
203
|
+
for (const name of Object.keys(content).sort()) {
|
|
204
|
+
const member = canonicalize(content[name]);
|
|
205
|
+
if (member !== undefined) {
|
|
206
|
+
(name < "signature" ? before : after).push(
|
|
207
|
+
`${JSON.stringify(name)}:${member}`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const head = Buffer.from(
|
|
212
|
+
before.length ? `{${before.join(",")},` : "{",
|
|
213
|
+
"utf8",
|
|
214
|
+
);
|
|
215
|
+
const tail = Buffer.from(after.length ? `,${after.join(",")}}` : "}", "utf8");
|
|
216
|
+
return (signature) =>
|
|
217
|
+
Buffer.concat([
|
|
218
|
+
head,
|
|
219
|
+
Buffer.from(`"signature":${canonicalize(signature)}`, "utf8"),
|
|
220
|
+
tail,
|
|
221
|
+
]);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function getAlgorithm(alg) {
|
|
225
|
+
const spec = typeof alg === "string" ? JSF_ALGORITHMS.get(alg) : undefined;
|
|
226
|
+
if (!spec) {
|
|
227
|
+
const name =
|
|
228
|
+
typeof alg === "string" ? alg : `<${alg === null ? "null" : typeof alg}>`;
|
|
229
|
+
throw new Error(
|
|
230
|
+
`Unsupported JSF algorithm: ${name}. Supported algorithms: ${[...JSF_ALGORITHMS.keys()].join(", ")}.`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
return spec;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function describeRequirement(spec) {
|
|
237
|
+
if (spec.keyType === "secret") {
|
|
238
|
+
return "a shared secret";
|
|
239
|
+
}
|
|
240
|
+
if (spec.keyType === "rsa-pss") {
|
|
241
|
+
return "an rsa or rsa-pss key";
|
|
242
|
+
}
|
|
243
|
+
if (spec.curve) {
|
|
244
|
+
return `an ec (${spec.curve}) key`;
|
|
245
|
+
}
|
|
246
|
+
return `an ${spec.keyType} key`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function describeKey(key) {
|
|
250
|
+
if (key.type === "secret") {
|
|
251
|
+
return "a shared secret";
|
|
252
|
+
}
|
|
253
|
+
const curve = key.asymmetricKeyDetails?.namedCurve;
|
|
254
|
+
return curve
|
|
255
|
+
? `${key.asymmetricKeyType} (${curve})`
|
|
256
|
+
: String(key.asymmetricKeyType);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function assertKeyMatchesAlgorithm(key, alg, spec) {
|
|
260
|
+
let matches;
|
|
261
|
+
if (spec.keyType === "secret" || key.type === "secret") {
|
|
262
|
+
matches = spec.keyType === "secret" && key.type === "secret";
|
|
263
|
+
} else if (spec.keyType === "rsa-pss") {
|
|
264
|
+
matches = ["rsa", "rsa-pss"].includes(key.asymmetricKeyType);
|
|
265
|
+
} else if (spec.keyType === "ec") {
|
|
266
|
+
matches =
|
|
267
|
+
key.asymmetricKeyType === "ec" &&
|
|
268
|
+
key.asymmetricKeyDetails?.namedCurve === spec.curve;
|
|
269
|
+
} else {
|
|
270
|
+
matches = key.asymmetricKeyType === spec.keyType;
|
|
271
|
+
}
|
|
272
|
+
if (!matches) {
|
|
273
|
+
throw new Error(
|
|
274
|
+
`Algorithm ${alg} requires ${describeRequirement(spec)}, but the key is ${describeKey(key)}.`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function parsesAsKey(create, input) {
|
|
280
|
+
try {
|
|
281
|
+
create(input);
|
|
282
|
+
return true;
|
|
283
|
+
} catch (err) {
|
|
284
|
+
return err?.code === "ERR_MISSING_PASSPHRASE";
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function isJwkText(material) {
|
|
289
|
+
try {
|
|
290
|
+
const parsed = JSON.parse(material.toString("utf8"));
|
|
291
|
+
return isPlainObject(parsed) && typeof parsed.kty === "string";
|
|
292
|
+
} catch {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function looksLikeAsymmetricKey(material) {
|
|
298
|
+
if (typeof material === "string" || Buffer.isBuffer(material)) {
|
|
299
|
+
if (material.includes("-----BEGIN ") || isJwkText(material)) {
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (
|
|
304
|
+
parsesAsKey(crypto.createPrivateKey, material) ||
|
|
305
|
+
parsesAsKey(crypto.createPublicKey, material)
|
|
306
|
+
) {
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
if (Buffer.isBuffer(material)) {
|
|
310
|
+
return DER_KEY_LAYOUTS.some(([create, type]) =>
|
|
311
|
+
parsesAsKey(create, { key: material, format: "der", type }),
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Wraps shared-secret material for HMAC (HS256, HS384, HS512) signing or
|
|
319
|
+
* verification.
|
|
35
320
|
*
|
|
36
|
-
*
|
|
321
|
+
* HMAC is only ever used with a key produced by this function (or any secret
|
|
322
|
+
* KeyObject). Public and private keys are refused, so a document cannot switch
|
|
323
|
+
* a verifier to HMAC and use its public key as the secret.
|
|
324
|
+
*
|
|
325
|
+
* @param {string|Buffer|crypto.KeyObject} material - Secret bytes, a UTF-8 string, or a secret KeyObject
|
|
326
|
+
* @returns {crypto.KeyObject} - A secret KeyObject
|
|
37
327
|
*/
|
|
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;
|
|
328
|
+
export function loadSharedSecret(material) {
|
|
329
|
+
if (material instanceof crypto.KeyObject) {
|
|
330
|
+
if (material.type !== "secret") {
|
|
331
|
+
throw new Error(
|
|
332
|
+
"HMAC signatures need a shared secret, but an asymmetric key was supplied.",
|
|
333
|
+
);
|
|
62
334
|
}
|
|
63
|
-
|
|
64
|
-
|
|
335
|
+
return material;
|
|
336
|
+
}
|
|
337
|
+
if (typeof material !== "string" && !Buffer.isBuffer(material)) {
|
|
338
|
+
throw new TypeError(
|
|
339
|
+
"The shared secret must be a string, a Buffer, or a secret KeyObject.",
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
if (looksLikeAsymmetricKey(material)) {
|
|
343
|
+
throw new Error(
|
|
344
|
+
"The shared secret looks like a public or private key. HMAC signatures must use a separate shared secret.",
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
const bytes =
|
|
348
|
+
typeof material === "string" ? Buffer.from(material, "utf8") : material;
|
|
349
|
+
if (!bytes.length) {
|
|
350
|
+
throw new Error("The shared secret is empty.");
|
|
351
|
+
}
|
|
352
|
+
return crypto.createSecretKey(bytes);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Loads the key used to verify signatures.
|
|
357
|
+
*
|
|
358
|
+
* Strings and Buffers are always parsed as asymmetric keys (a private key is
|
|
359
|
+
* reduced to its public half). To verify HMAC signatures, pass the result of
|
|
360
|
+
* {@link loadSharedSecret} instead.
|
|
361
|
+
*
|
|
362
|
+
* @param {string|Buffer|Object|crypto.KeyObject} key - PEM text, a createPublicKey() input, or a KeyObject
|
|
363
|
+
* @returns {crypto.KeyObject} - A public or secret KeyObject
|
|
364
|
+
*/
|
|
365
|
+
export function loadVerificationKey(key) {
|
|
366
|
+
if (key instanceof crypto.KeyObject) {
|
|
367
|
+
return key.type === "private" ? crypto.createPublicKey(key) : key;
|
|
368
|
+
}
|
|
369
|
+
try {
|
|
370
|
+
return crypto.createPublicKey(key);
|
|
371
|
+
} catch (err) {
|
|
372
|
+
throw new Error(`Unable to load the verification key: ${err.message}`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function loadSigningKey(privateKey, alg, spec) {
|
|
377
|
+
let key;
|
|
378
|
+
if (spec.keyType === "secret") {
|
|
379
|
+
key = loadSharedSecret(privateKey);
|
|
380
|
+
} else if (privateKey instanceof crypto.KeyObject) {
|
|
381
|
+
key = privateKey;
|
|
382
|
+
} else {
|
|
383
|
+
try {
|
|
384
|
+
key = crypto.createPrivateKey(privateKey);
|
|
385
|
+
} catch (err) {
|
|
386
|
+
throw new Error(`Unable to load the signing key: ${err.message}`);
|
|
65
387
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
388
|
+
}
|
|
389
|
+
assertKeyMatchesAlgorithm(key, alg, spec);
|
|
390
|
+
if (spec.keyType !== "secret" && key.type !== "private") {
|
|
391
|
+
throw new Error(
|
|
392
|
+
`Signing with ${alg} needs a private key, but a ${key.type} key was supplied.`,
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
return key;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Generates a PEM key pair suitable for the given JSF algorithm.
|
|
400
|
+
*
|
|
401
|
+
* @param {string} algorithm - JSF algorithm identifier
|
|
402
|
+
* @returns {{ publicKey: string, privateKey: string }} - PEM encoded SPKI public key and PKCS#8 private key
|
|
403
|
+
*/
|
|
404
|
+
export function generateSigningKeyPair(algorithm) {
|
|
405
|
+
const spec = getAlgorithm(algorithm);
|
|
406
|
+
const encoding = {
|
|
407
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
408
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
409
|
+
};
|
|
410
|
+
switch (spec.keyType) {
|
|
411
|
+
case "secret":
|
|
412
|
+
throw new Error(
|
|
413
|
+
`${algorithm} uses a shared secret, so there is no key pair to generate.`,
|
|
414
|
+
);
|
|
415
|
+
case "rsa":
|
|
416
|
+
case "rsa-pss":
|
|
417
|
+
return crypto.generateKeyPairSync("rsa", {
|
|
418
|
+
modulusLength: 4096,
|
|
419
|
+
...encoding,
|
|
420
|
+
});
|
|
421
|
+
case "ec":
|
|
422
|
+
return crypto.generateKeyPairSync("ec", {
|
|
423
|
+
namedCurve: spec.curve,
|
|
424
|
+
...encoding,
|
|
425
|
+
});
|
|
426
|
+
default:
|
|
427
|
+
return crypto.generateKeyPairSync(spec.keyType, encoding);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function signatureOptions(key, spec) {
|
|
432
|
+
const options = { key };
|
|
433
|
+
if (spec.keyType === "rsa-pss") {
|
|
434
|
+
options.padding = crypto.constants.RSA_PKCS1_PSS_PADDING;
|
|
435
|
+
// JWA fixes the PSS salt length to the digest length.
|
|
436
|
+
options.saltLength = crypto.constants.RSA_PSS_SALTLEN_DIGEST;
|
|
437
|
+
} else if (spec.keyType === "ec") {
|
|
78
438
|
// Standard JWA format requires IEEE P1363 (R || S) instead of ASN.1 DER
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
439
|
+
options.dsaEncoding = "ieee-p1363";
|
|
440
|
+
}
|
|
441
|
+
return options;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Returns the ECDSA signature (r || s) with s in the lower half of the curve
|
|
446
|
+
* order. (r, s) and (r, n - s) both verify, so emitting only the low form gives
|
|
447
|
+
* each signature a single encoding.
|
|
448
|
+
*/
|
|
449
|
+
function toLowS(signature, order) {
|
|
450
|
+
const size = signature.length / 2;
|
|
451
|
+
const s = BigInt(`0x${signature.subarray(size).toString("hex")}`);
|
|
452
|
+
if (s <= order >> 1n) {
|
|
453
|
+
return signature;
|
|
454
|
+
}
|
|
455
|
+
const lowS = Buffer.from(
|
|
456
|
+
(order - s).toString(16).padStart(size * 2, "0"),
|
|
457
|
+
"hex",
|
|
458
|
+
);
|
|
459
|
+
return Buffer.concat([signature.subarray(0, size), lowS]);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function computeSignature(data, key, spec) {
|
|
463
|
+
if (spec.keyType === "secret") {
|
|
464
|
+
return crypto.createHmac(spec.hash, key).update(data).digest();
|
|
85
465
|
}
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
466
|
+
const signature = crypto.sign(spec.hash, data, signatureOptions(key, spec));
|
|
467
|
+
return spec.order ? toLowS(signature, spec.order) : signature;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function signatureMatches(data, key, spec, signature) {
|
|
471
|
+
if (spec.keyType === "secret") {
|
|
472
|
+
const expected = crypto.createHmac(spec.hash, key).update(data).digest();
|
|
473
|
+
return (
|
|
474
|
+
signature.length === expected.length &&
|
|
475
|
+
crypto.timingSafeEqual(signature, expected)
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
return crypto.verify(spec.hash, data, signatureOptions(key, spec), signature);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// KeyObject.equals() on keys of different types leaves an OpenSSL error queued,
|
|
482
|
+
// which the next unrelated key operation in the process then throws. Compare
|
|
483
|
+
// the key types first so a hostile embedded key cannot disturb later checks.
|
|
484
|
+
function isSameKey(a, b) {
|
|
485
|
+
return (
|
|
486
|
+
a.type === b.type &&
|
|
487
|
+
a.asymmetricKeyType === b.asymmetricKeyType &&
|
|
488
|
+
a.asymmetricKeyDetails?.namedCurve === b.asymmetricKeyDetails?.namedCurve &&
|
|
489
|
+
a.equals(b)
|
|
90
490
|
);
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function publicKeyFromJwk(jwk) {
|
|
494
|
+
if (!isPlainObject(jwk)) {
|
|
495
|
+
throw new Error("The signature publicKey must be a JWK object.");
|
|
496
|
+
}
|
|
497
|
+
return crypto.createPublicKey({ key: jwk, format: "jwk" });
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function embeddablePublicKey(publicKeyJwk, signingKey, alg) {
|
|
501
|
+
if (!publicKeyJwk) {
|
|
502
|
+
return undefined;
|
|
503
|
+
}
|
|
504
|
+
if (signingKey.type === "secret") {
|
|
505
|
+
throw new Error(
|
|
506
|
+
`${alg} signatures use a shared secret, so a public key cannot be embedded.`,
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
const derived = crypto.createPublicKey(signingKey);
|
|
510
|
+
if (!isSameKey(publicKeyFromJwk(publicKeyJwk), derived)) {
|
|
511
|
+
throw new Error(
|
|
512
|
+
"The public key to embed in the signature does not belong to the signing key.",
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
// Export from the derived key so that only public members are ever embedded.
|
|
516
|
+
return derived.export({ format: "jwk" });
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Splits a JSF signature property into its signature objects.
|
|
521
|
+
*
|
|
522
|
+
* @returns {{ form: "single"|"signers"|"chain", entries: Object[], wrapper: Object }}
|
|
523
|
+
*/
|
|
524
|
+
function readSignatureEntries(signature) {
|
|
525
|
+
if (!isPlainObject(signature)) {
|
|
526
|
+
throw new Error("The signature must be a JSON object.");
|
|
527
|
+
}
|
|
528
|
+
const hasSigners = Object.hasOwn(signature, "signers");
|
|
529
|
+
const hasChain = Object.hasOwn(signature, "chain");
|
|
530
|
+
if (hasSigners && hasChain) {
|
|
531
|
+
throw new Error("A signature cannot contain both signers and chain.");
|
|
532
|
+
}
|
|
533
|
+
if (!hasSigners && !hasChain) {
|
|
534
|
+
return { form: "single", entries: [signature], wrapper: {} };
|
|
535
|
+
}
|
|
536
|
+
const form = hasSigners ? "signers" : "chain";
|
|
537
|
+
const { [form]: entries, ...wrapper } = signature;
|
|
538
|
+
const wrapperMembers = Object.keys(wrapper);
|
|
539
|
+
if (wrapperMembers.includes("excludes")) {
|
|
540
|
+
throw new Error("Signatures that use JSF excludes are not supported.");
|
|
94
541
|
}
|
|
95
|
-
if (
|
|
96
|
-
|
|
542
|
+
if (wrapperMembers.length) {
|
|
543
|
+
throw new Error(
|
|
544
|
+
`A ${form} signature may only hold the ${form} array, but it also holds ${listMembers(wrapperMembers)}.`,
|
|
545
|
+
);
|
|
97
546
|
}
|
|
98
|
-
|
|
547
|
+
if (
|
|
548
|
+
!Array.isArray(entries) ||
|
|
549
|
+
!entries.length ||
|
|
550
|
+
!entries.every(isPlainObject)
|
|
551
|
+
) {
|
|
552
|
+
throw new Error(`The ${form} array must contain signature objects.`);
|
|
553
|
+
}
|
|
554
|
+
if (entries.length > MAX_SIGNATURE_ENTRIES) {
|
|
555
|
+
throw new Error(
|
|
556
|
+
`The ${form} array has ${entries.length} entries; at most ${MAX_SIGNATURE_ENTRIES} are supported.`,
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
return { form, entries, wrapper };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function withoutValue(entry) {
|
|
563
|
+
const { value: _value, ...rest } = entry;
|
|
564
|
+
return rest;
|
|
99
565
|
}
|
|
100
566
|
|
|
101
567
|
/**
|
|
102
|
-
*
|
|
568
|
+
* Builds the signature member that is signed for entry `index`.
|
|
569
|
+
*
|
|
570
|
+
* Following JSF, only the `value` of the entry being processed is removed.
|
|
571
|
+
* Other `signers` are removed entirely, while a `chain` entry keeps every
|
|
572
|
+
* earlier entry (including its value) and drops the later ones.
|
|
103
573
|
*/
|
|
104
|
-
function
|
|
105
|
-
if (
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
574
|
+
function jsfSignedSignature(form, entries, wrapper, index) {
|
|
575
|
+
if (form === "single") {
|
|
576
|
+
return withoutValue(entries[index]);
|
|
577
|
+
}
|
|
578
|
+
const kept =
|
|
579
|
+
form === "signers"
|
|
580
|
+
? [withoutValue(entries[index])]
|
|
581
|
+
: [...entries.slice(0, index), withoutValue(entries[index])];
|
|
582
|
+
return { ...wrapper, [form]: kept };
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function candidateSignatures(parsed, index) {
|
|
586
|
+
const signatures = [
|
|
587
|
+
jsfSignedSignature(parsed.form, parsed.entries, parsed.wrapper, index),
|
|
588
|
+
];
|
|
589
|
+
// Appending a signers or chain entry to a single signature moves that
|
|
590
|
+
// signature into the new array. It was signed in the single signature form,
|
|
591
|
+
// which asserts the same thing as a signers entry at any position, and as a
|
|
592
|
+
// chain entry at position 0, where nothing precedes it.
|
|
593
|
+
if (
|
|
594
|
+
(parsed.form === "signers" || (parsed.form === "chain" && index === 0)) &&
|
|
595
|
+
!Object.keys(parsed.wrapper).length
|
|
596
|
+
) {
|
|
597
|
+
signatures.push(jsfSignedSignature("single", parsed.entries, {}, index));
|
|
598
|
+
}
|
|
599
|
+
return signatures;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Parses a node's signature and returns a function that builds the signed bytes
|
|
604
|
+
* of an entry. The node content is canonicalized once, on first use.
|
|
605
|
+
*
|
|
606
|
+
* @param {Object} node - A signed BOM, component, service, or annotation
|
|
607
|
+
* @returns {{ parsed: Object, signedData: function(number): Buffer[] }}
|
|
608
|
+
*/
|
|
609
|
+
function prepareSignedNode(node) {
|
|
610
|
+
const parsed = readSignatureEntries(node.signature);
|
|
611
|
+
const { signature: _signature, ...content } = node;
|
|
612
|
+
let serialize;
|
|
613
|
+
return {
|
|
614
|
+
parsed,
|
|
615
|
+
signedData(index) {
|
|
616
|
+
serialize ??= signedViewSerializer(content);
|
|
617
|
+
return candidateSignatures(parsed, index).map(serialize);
|
|
618
|
+
},
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function decodeSignatureValue(value) {
|
|
623
|
+
if (typeof value !== "string" || !value.length) {
|
|
624
|
+
throw new Error("The signature value is missing.");
|
|
625
|
+
}
|
|
626
|
+
const signature = Buffer.from(value, "base64url");
|
|
627
|
+
if (signature.toString("base64url") !== value) {
|
|
628
|
+
throw new Error("The signature value is not canonical base64url.");
|
|
629
|
+
}
|
|
630
|
+
return signature;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Checks one signature entry with one key. `signedData` returns the bytes the
|
|
635
|
+
* entry may cover; it is only called once the entry and key are compatible.
|
|
636
|
+
*/
|
|
637
|
+
function verifyEntry(entry, key, signedData) {
|
|
638
|
+
const spec = getAlgorithm(entry.algorithm);
|
|
639
|
+
const signature = decodeSignatureValue(entry.value);
|
|
640
|
+
if (Object.hasOwn(entry, "excludes")) {
|
|
641
|
+
throw new Error("Signatures that use JSF excludes are not supported.");
|
|
642
|
+
}
|
|
643
|
+
assertKeyMatchesAlgorithm(key, entry.algorithm, spec);
|
|
644
|
+
if (entry.publicKey !== undefined) {
|
|
645
|
+
if (key.type === "secret") {
|
|
646
|
+
throw new Error("HMAC signatures must not embed a public key.");
|
|
116
647
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
throw new Error("Cannot mix signature chains and multi-signers.");
|
|
122
|
-
} else {
|
|
123
|
-
target.signature = { signers: [target.signature, sigBlock] };
|
|
648
|
+
if (!isSameKey(publicKeyFromJwk(entry.publicKey), key)) {
|
|
649
|
+
throw new Error(
|
|
650
|
+
"The embedded publicKey does not match the verification key.",
|
|
651
|
+
);
|
|
124
652
|
}
|
|
125
|
-
} else {
|
|
126
|
-
target.signature = sigBlock;
|
|
127
653
|
}
|
|
654
|
+
return signedData().some((data) =>
|
|
655
|
+
signatureMatches(data, key, spec, signature),
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function mismatchReason(entry) {
|
|
660
|
+
return `The ${displayValue(entry.algorithm)} signature does not verify with this key: the signed content changed, or a different key made it.`;
|
|
128
661
|
}
|
|
129
662
|
|
|
130
663
|
/**
|
|
131
|
-
*
|
|
664
|
+
* Checks every entry of a node's signature against the trusted keys.
|
|
665
|
+
*
|
|
666
|
+
* @param {Object} node - The signed object
|
|
667
|
+
* @param {crypto.KeyObject[]} keys - Loaded verification keys
|
|
668
|
+
* @param {Object} [options]
|
|
669
|
+
* @param {boolean} [options.requireAll] - The node verifies only when every entry does
|
|
670
|
+
* @param {boolean} [options.requireEveryKey] - With requireAll, every key must also verify at least one entry
|
|
671
|
+
* @param {boolean} [options.firstMatch] - Stop at the first entry that verifies
|
|
672
|
+
* @returns {{ form: string|undefined, verified: boolean, match: Object|null, reasons: string[], entries: Object[], keysMatched: boolean[] }}
|
|
673
|
+
* `reasons` explains a signature that could not be read; each entry carries its own reasons.
|
|
674
|
+
* `keysMatched[i]` tells whether `keys[i]` verified at least one entry.
|
|
675
|
+
*/
|
|
676
|
+
function checkNode(node, keys, options = {}) {
|
|
677
|
+
const result = {
|
|
678
|
+
form: undefined,
|
|
679
|
+
verified: false,
|
|
680
|
+
match: null,
|
|
681
|
+
reasons: [],
|
|
682
|
+
entries: [],
|
|
683
|
+
keysMatched: keys.map(() => false),
|
|
684
|
+
};
|
|
685
|
+
let prepared;
|
|
686
|
+
try {
|
|
687
|
+
prepared = prepareSignedNode(node);
|
|
688
|
+
} catch (err) {
|
|
689
|
+
result.reasons.push(err.message);
|
|
690
|
+
return result;
|
|
691
|
+
}
|
|
692
|
+
const { parsed } = prepared;
|
|
693
|
+
result.form = parsed.form;
|
|
694
|
+
for (const [index, entry] of parsed.entries.entries()) {
|
|
695
|
+
const reasons = [];
|
|
696
|
+
let verified = false;
|
|
697
|
+
// The signed bytes of one entry are built at most once and released before
|
|
698
|
+
// the next entry, so memory stays proportional to the node size.
|
|
699
|
+
let data;
|
|
700
|
+
const signedData = () => {
|
|
701
|
+
data ??= prepared.signedData(index);
|
|
702
|
+
return data;
|
|
703
|
+
};
|
|
704
|
+
for (const [keyIndex, key] of keys.entries()) {
|
|
705
|
+
try {
|
|
706
|
+
if (verifyEntry(entry, key, signedData)) {
|
|
707
|
+
verified = true;
|
|
708
|
+
result.keysMatched[keyIndex] = true;
|
|
709
|
+
if (!options.requireAll) {
|
|
710
|
+
break;
|
|
711
|
+
}
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
reasons.push(mismatchReason(entry));
|
|
715
|
+
} catch (err) {
|
|
716
|
+
reasons.push(err.message);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
result.entries.push({
|
|
720
|
+
index,
|
|
721
|
+
algorithm: entry.algorithm,
|
|
722
|
+
keyId: entry.keyId,
|
|
723
|
+
verified,
|
|
724
|
+
reasons: verified ? [] : [...new Set(reasons)],
|
|
725
|
+
});
|
|
726
|
+
if (verified && !result.match) {
|
|
727
|
+
result.match = entry;
|
|
728
|
+
if (options.firstMatch) {
|
|
729
|
+
break;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
result.verified = options.requireAll
|
|
734
|
+
? result.entries.length === parsed.entries.length &&
|
|
735
|
+
result.entries.every((entry) => entry.verified) &&
|
|
736
|
+
(!options.requireEveryKey || result.keysMatched.every(Boolean))
|
|
737
|
+
: Boolean(result.match);
|
|
738
|
+
return result;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function entryReasons(result) {
|
|
742
|
+
if (result.reasons.length) {
|
|
743
|
+
return result.reasons;
|
|
744
|
+
}
|
|
745
|
+
return result.entries.flatMap((entry) =>
|
|
746
|
+
entry.reasons.map((reason) =>
|
|
747
|
+
result.form === "single"
|
|
748
|
+
? reason
|
|
749
|
+
: `${result.form}[${entry.index}]: ${reason}`,
|
|
750
|
+
),
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function nestedLabel(field, index, item) {
|
|
755
|
+
let name = index;
|
|
756
|
+
if (isPlainObject(item)) {
|
|
757
|
+
name =
|
|
758
|
+
item["bom-ref"] ||
|
|
759
|
+
item.name ||
|
|
760
|
+
(Array.isArray(item.subjects) && item.subjects.length
|
|
761
|
+
? item.subjects
|
|
762
|
+
: index);
|
|
763
|
+
}
|
|
764
|
+
return `${field}[${index}] '${displayValue(name)}'`;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function signatureLayout(target, mode) {
|
|
768
|
+
const existing = target.signature;
|
|
769
|
+
if (mode === "replace" || existing === undefined) {
|
|
770
|
+
return {
|
|
771
|
+
form: mode === "replace" ? "single" : mode,
|
|
772
|
+
entries: [],
|
|
773
|
+
wrapper: {},
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
const parsed = readSignatureEntries(existing);
|
|
777
|
+
if (parsed.form !== "single" && parsed.form !== mode) {
|
|
778
|
+
throw new Error("Cannot mix signature chains and multi-signers.");
|
|
779
|
+
}
|
|
780
|
+
if (parsed.entries.length >= MAX_SIGNATURE_ENTRIES) {
|
|
781
|
+
throw new Error(
|
|
782
|
+
`Cannot append another ${mode} entry: a signature holds at most ${MAX_SIGNATURE_ENTRIES} entries.`,
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
return parsed.form === "single"
|
|
786
|
+
? { form: mode, entries: parsed.entries, wrapper: {} }
|
|
787
|
+
: parsed;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function addSignature(target, key, spec, template, layout) {
|
|
791
|
+
const { signature: _existing, ...content } = target;
|
|
792
|
+
const entry = structuredClone(template);
|
|
793
|
+
const entries = [...layout.entries, entry];
|
|
794
|
+
const index = entries.length - 1;
|
|
795
|
+
const signedData = signedViewSerializer(content)(
|
|
796
|
+
jsfSignedSignature(layout.form, entries, layout.wrapper, index),
|
|
797
|
+
);
|
|
798
|
+
entry.value = computeSignature(signedData, key, spec).toString("base64url");
|
|
799
|
+
target.signature =
|
|
800
|
+
layout.form === "single"
|
|
801
|
+
? entry
|
|
802
|
+
: { ...layout.wrapper, [layout.form]: entries };
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function signatureTemplate(algorithm, publicKey, keyId) {
|
|
806
|
+
const template = { algorithm };
|
|
807
|
+
if (publicKey) {
|
|
808
|
+
template.publicKey = publicKey;
|
|
809
|
+
}
|
|
810
|
+
if (keyId !== null && keyId !== undefined && keyId !== "") {
|
|
811
|
+
template.keyId = String(keyId);
|
|
812
|
+
}
|
|
813
|
+
return template;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Applies JSF signatures to the BOM and, optionally, its components, services,
|
|
818
|
+
* and annotations.
|
|
819
|
+
*
|
|
820
|
+
* Nested elements are signed by default. When a `signers` or `chain` signature
|
|
821
|
+
* is appended to a BOM that already has a root signature, nested elements are
|
|
822
|
+
* skipped by default, because changing them would invalidate the existing root
|
|
823
|
+
* signature; explicitly requesting nested signing in that case is an error.
|
|
824
|
+
*
|
|
825
|
+
* The BOM root must carry `bomFormat` (or `specFormat`) and `specVersion`, and
|
|
826
|
+
* only members that CycloneDX defines at the root. A nested element that
|
|
827
|
+
* carries `bomFormat`, `specFormat`, or `specVersion` is refused. Verification
|
|
828
|
+
* rejects signatures over either shape.
|
|
132
829
|
*
|
|
133
830
|
* @param {Object} bomJson - CycloneDX BOM Object
|
|
134
|
-
* @param {Object} options - Signing options
|
|
831
|
+
* @param {Object} options - Signing options
|
|
832
|
+
* @param {string|Buffer|Object|crypto.KeyObject} options.privateKey - Private key (PEM or KeyObject), or the shared secret for HS* algorithms
|
|
833
|
+
* @param {string} [options.algorithm] - JSF algorithm identifier (default RS512). It must match the key type.
|
|
834
|
+
* @param {Object} [options.publicKeyJwk] - JWK of the signing key's public key to embed
|
|
835
|
+
* @param {boolean} [options.embedPublicKey] - Embed the public key derived from the signing key when publicKeyJwk is not given. Ignored for HS* algorithms and for keys without a JWK form, such as rsa-pss.
|
|
836
|
+
* @param {boolean} [options.embedPublicKeyNested] - Also embed the public key in nested signatures (default true)
|
|
837
|
+
* @param {string} [options.keyId] - Key ID to embed
|
|
838
|
+
* @param {string} [options.mode] - 'replace' (default), 'signers', or 'chain'
|
|
839
|
+
* @param {boolean} [options.signComponents] - Sign each component
|
|
840
|
+
* @param {boolean} [options.signServices] - Sign each service
|
|
841
|
+
* @param {boolean} [options.signAnnotations] - Sign each annotation
|
|
135
842
|
* @returns {Object} - Signed BOM Object
|
|
136
843
|
*/
|
|
137
844
|
export function signBom(bomJson, options = {}) {
|
|
@@ -139,174 +846,346 @@ export function signBom(bomJson, options = {}) {
|
|
|
139
846
|
privateKey,
|
|
140
847
|
algorithm = "RS512",
|
|
141
848
|
publicKeyJwk = null,
|
|
849
|
+
embedPublicKeyNested = true,
|
|
142
850
|
keyId = null,
|
|
143
|
-
mode = "replace",
|
|
144
|
-
signComponents = true,
|
|
145
|
-
signServices = true,
|
|
146
|
-
signAnnotations = true,
|
|
851
|
+
mode = "replace",
|
|
147
852
|
} = options;
|
|
148
853
|
|
|
149
854
|
if (!privateKey) {
|
|
150
855
|
throw new Error("privateKey is required for signing");
|
|
151
856
|
}
|
|
152
|
-
if (
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
createSignatureBlock(comp, privateKey, algorithm, publicKeyJwk, keyId),
|
|
157
|
-
mode,
|
|
158
|
-
);
|
|
159
|
-
}
|
|
857
|
+
if (!SIGNATURE_MODES.includes(mode)) {
|
|
858
|
+
throw new Error(
|
|
859
|
+
`Unsupported signature mode: ${mode}. Use one of ${SIGNATURE_MODES.join(", ")}.`,
|
|
860
|
+
);
|
|
160
861
|
}
|
|
161
|
-
if (
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
862
|
+
if (!isPlainObject(bomJson)) {
|
|
863
|
+
throw new Error("The BOM must be a JSON object.");
|
|
864
|
+
}
|
|
865
|
+
if (!hasBomIdentity(bomJson)) {
|
|
866
|
+
throw new Error(
|
|
867
|
+
'Only a CycloneDX BOM can be signed: its root needs bomFormat (or specFormat) "CycloneDX" and a specVersion.',
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
const unexpected = unexpectedRootMembers(bomJson);
|
|
871
|
+
if (unexpected.length) {
|
|
872
|
+
throw new Error(
|
|
873
|
+
`The BOM has members that CycloneDX does not define at the root of a BOM: ${listMembers(unexpected)}. Remove them before signing.`,
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
const spec = getAlgorithm(algorithm);
|
|
877
|
+
const signingKey = loadSigningKey(privateKey, algorithm, spec);
|
|
878
|
+
let publicKey = embeddablePublicKey(publicKeyJwk, signingKey, algorithm);
|
|
879
|
+
if (!publicKey && options.embedPublicKey && signingKey.type !== "secret") {
|
|
880
|
+
try {
|
|
881
|
+
publicKey = crypto.createPublicKey(signingKey).export({ format: "jwk" });
|
|
882
|
+
} catch {
|
|
883
|
+
// Some key types, such as rsa-pss, have no JWK form and are not embedded.
|
|
884
|
+
publicKey = undefined;
|
|
168
885
|
}
|
|
169
886
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
887
|
+
const rootTemplate = signatureTemplate(algorithm, publicKey, keyId);
|
|
888
|
+
const nestedTemplate = signatureTemplate(
|
|
889
|
+
algorithm,
|
|
890
|
+
embedPublicKeyNested ? publicKey : undefined,
|
|
891
|
+
keyId,
|
|
892
|
+
);
|
|
893
|
+
|
|
894
|
+
const appending = mode !== "replace" && bomJson.signature !== undefined;
|
|
895
|
+
const requested = {
|
|
896
|
+
components: options.signComponents,
|
|
897
|
+
services: options.signServices,
|
|
898
|
+
annotations: options.signAnnotations,
|
|
899
|
+
};
|
|
900
|
+
// Work out every signature layout before signing anything, so a rejected
|
|
901
|
+
// request leaves the BOM untouched.
|
|
902
|
+
const plan = [];
|
|
903
|
+
for (const field of NESTED_TARGETS) {
|
|
904
|
+
const items = Array.isArray(bomJson[field]) ? bomJson[field] : [];
|
|
905
|
+
const enabled =
|
|
906
|
+
requested[field] === undefined ? !appending : Boolean(requested[field]);
|
|
907
|
+
if (!enabled || !items.length) {
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
if (appending) {
|
|
911
|
+
throw new Error(
|
|
912
|
+
`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
913
|
);
|
|
177
914
|
}
|
|
915
|
+
for (const [index, item] of items.entries()) {
|
|
916
|
+
if (!isPlainObject(item)) {
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
919
|
+
const identity = bomIdentityMembers(item);
|
|
920
|
+
if (identity.length) {
|
|
921
|
+
throw new Error(
|
|
922
|
+
`Cannot sign ${nestedLabel(field, index, item)}: it carries ${identity.join(", ")}, which only the root of a BOM may hold.`,
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
plan.push([item, signatureLayout(item, mode)]);
|
|
926
|
+
}
|
|
178
927
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
);
|
|
928
|
+
const rootLayout = signatureLayout(bomJson, mode);
|
|
929
|
+
for (const [item, layout] of plan) {
|
|
930
|
+
addSignature(item, signingKey, spec, nestedTemplate, layout);
|
|
931
|
+
}
|
|
932
|
+
addSignature(bomJson, signingKey, spec, rootTemplate, rootLayout);
|
|
184
933
|
return bomJson;
|
|
185
934
|
}
|
|
186
935
|
|
|
187
936
|
/**
|
|
188
|
-
*
|
|
937
|
+
* Verifies the signature of a single node (e.g., BOM root, Component, Service,
|
|
938
|
+
* Annotation). Handles single signatures, multi-signatures (signers), and
|
|
939
|
+
* signature chains, and returns the first signature object that matches the key.
|
|
189
940
|
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
941
|
+
* A signature only matches when its declared algorithm fits the key type, its
|
|
942
|
+
* embedded publicKey (if any) is the verification key, and its value covers the
|
|
943
|
+
* node content plus the signature metadata as JSF defines.
|
|
193
944
|
*
|
|
194
|
-
*
|
|
945
|
+
* This checks one object only. To verify a BOM, use {@link verifyBomSignatures}
|
|
946
|
+
* or {@link verifyBom}, which also require the signed root to be a BOM.
|
|
947
|
+
*
|
|
948
|
+
* @param {Object} node - The BOM or granular object to verify
|
|
949
|
+
* @param {string|Buffer|Object|crypto.KeyObject} publicKey - Verification key. Strings and Buffers are parsed as public keys; use loadSharedSecret() for HMAC.
|
|
950
|
+
* @param {Object} [options]
|
|
951
|
+
* @param {string[]} [options.reasons] - When no signature matches, receives the reason each entry was rejected
|
|
952
|
+
* @returns {boolean|Object} - Matching signature object if valid. False otherwise.
|
|
195
953
|
*/
|
|
196
|
-
function
|
|
197
|
-
|
|
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.`);
|
|
954
|
+
export function verifyNode(node, publicKey, options = {}) {
|
|
955
|
+
if (!isPlainObject(node) || node.signature === undefined) {
|
|
228
956
|
return false;
|
|
229
957
|
}
|
|
230
|
-
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
958
|
+
const key = loadVerificationKey(publicKey);
|
|
959
|
+
const result = checkNode(node, [key], { firstMatch: true });
|
|
960
|
+
if (result.match) {
|
|
961
|
+
return result.match;
|
|
962
|
+
}
|
|
963
|
+
if (Array.isArray(options.reasons)) {
|
|
964
|
+
options.reasons.push(...entryReasons(result));
|
|
965
|
+
}
|
|
966
|
+
return false;
|
|
238
967
|
}
|
|
239
968
|
|
|
240
969
|
/**
|
|
241
|
-
* Verifies the
|
|
242
|
-
*
|
|
970
|
+
* Verifies the signatures of a BOM against one or more trusted keys and reports
|
|
971
|
+
* the outcome for every signature entry.
|
|
243
972
|
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
973
|
+
* The BOM verifies when:
|
|
974
|
+
* - its root carries `bomFormat` (or `specFormat`) "CycloneDX" and
|
|
975
|
+
* `specVersion`, and only members that CycloneDX defines at the root of a BOM;
|
|
976
|
+
* - its root signature verifies, meaning at least one entry verifies with one
|
|
977
|
+
* of the keys. With `requireAll`, every entry must verify with one of the
|
|
978
|
+
* keys, and every key must verify at least one entry;
|
|
979
|
+
* - with `deep`, every signed component, service, and annotation verifies the
|
|
980
|
+
* same way and carries none of `bomFormat`, `specFormat`, and `specVersion`.
|
|
981
|
+
*
|
|
982
|
+
* A BOM without a root signature fails unless `allowNestedOnly` is set. Then
|
|
983
|
+
* every component, service, and annotation must carry a signature that
|
|
984
|
+
* verifies, and `notices` lists the members that no signature covers.
|
|
985
|
+
*
|
|
986
|
+
* Parse untrusted BOM text with `parseStrictJson()` from
|
|
987
|
+
* `lib/parsers/strictJson.js` before calling this function, so that the
|
|
988
|
+
* verified values are the values every JSON parser reads.
|
|
989
|
+
*
|
|
990
|
+
* @param {Object} bom - CycloneDX BOM Object
|
|
991
|
+
* @param {Array<string|Buffer|Object|crypto.KeyObject>|string|Buffer|Object|crypto.KeyObject} keys - Trusted verification keys (see verifyNode)
|
|
992
|
+
* @param {Object} [options]
|
|
993
|
+
* @param {boolean} [options.deep] - Also verify nested signatures (default true)
|
|
994
|
+
* @param {boolean} [options.allowNestedOnly] - Accept a BOM without a root signature when every nested element is signed
|
|
995
|
+
* @param {boolean} [options.requireAll] - Require every entry of each signature to verify, and every key to verify an entry of the root signature
|
|
996
|
+
* @returns {{ verified: boolean, errors: string[], notices: string[], root: Object|null, nested: Object[] }}
|
|
997
|
+
* `root` and each `nested` item hold `label`, `form`, `verified`, `match` (the first verified entry),
|
|
998
|
+
* `reasons` (why the signature could not be read), `entries` (`index`, `algorithm`, `keyId`, `verified`, `reasons`),
|
|
999
|
+
* and `keysMatched` (whether each key verified an entry).
|
|
247
1000
|
*/
|
|
248
|
-
export function
|
|
249
|
-
|
|
250
|
-
|
|
1001
|
+
export function verifyBomSignatures(bom, keys, options = {}) {
|
|
1002
|
+
const deep = options.deep !== false;
|
|
1003
|
+
const allowNestedOnly = Boolean(options.allowNestedOnly);
|
|
1004
|
+
const requireAll = Boolean(options.requireAll);
|
|
1005
|
+
const trustedKeys = (Array.isArray(keys) ? keys : [keys]).map((key) =>
|
|
1006
|
+
loadVerificationKey(key),
|
|
1007
|
+
);
|
|
1008
|
+
if (!trustedKeys.length) {
|
|
1009
|
+
throw new Error("At least one verification key is required.");
|
|
251
1010
|
}
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
1011
|
+
const report = {
|
|
1012
|
+
verified: false,
|
|
1013
|
+
errors: [],
|
|
1014
|
+
notices: [],
|
|
1015
|
+
root: null,
|
|
1016
|
+
nested: [],
|
|
1017
|
+
};
|
|
1018
|
+
if (!isPlainObject(bom)) {
|
|
1019
|
+
report.errors.push("The BOM must be a JSON object.");
|
|
1020
|
+
return report;
|
|
1021
|
+
}
|
|
1022
|
+
if (!hasBomIdentity(bom)) {
|
|
1023
|
+
report.errors.push(
|
|
1024
|
+
'The signed object is not a CycloneDX BOM: its root needs bomFormat (or specFormat) "CycloneDX" and a specVersion.',
|
|
1025
|
+
);
|
|
1026
|
+
}
|
|
1027
|
+
const unexpected = unexpectedRootMembers(bom);
|
|
1028
|
+
if (unexpected.length) {
|
|
1029
|
+
report.errors.push(
|
|
1030
|
+
`The BOM has members that CycloneDX does not define at the root of a BOM: ${listMembers(unexpected)}.`,
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
const nestedOnly = bom.signature === undefined;
|
|
1034
|
+
if (!nestedOnly) {
|
|
1035
|
+
report.root = {
|
|
1036
|
+
label: "BOM",
|
|
1037
|
+
...checkNode(bom, trustedKeys, { requireAll, requireEveryKey: true }),
|
|
1038
|
+
};
|
|
1039
|
+
const unmatched = report.root.keysMatched.flatMap((matched, index) =>
|
|
1040
|
+
matched ? [] : [`#${index + 1}`],
|
|
1041
|
+
);
|
|
1042
|
+
if (requireAll && report.root.form && unmatched.length) {
|
|
1043
|
+
report.errors.push(
|
|
1044
|
+
`No entry of the root signature verifies with key ${unmatched.join(", ")}, so a signature expected from that key is missing.`,
|
|
1045
|
+
);
|
|
259
1046
|
}
|
|
260
|
-
|
|
1047
|
+
} else if (!allowNestedOnly) {
|
|
1048
|
+
report.errors.push(
|
|
1049
|
+
"The BOM has no root signature, so no signature covers the BOM as a whole.",
|
|
1050
|
+
);
|
|
1051
|
+
} else if (!deep) {
|
|
1052
|
+
report.errors.push(
|
|
1053
|
+
"The BOM has no root signature, and nested signatures were not checked.",
|
|
1054
|
+
);
|
|
261
1055
|
}
|
|
262
|
-
if (
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
1056
|
+
if (deep) {
|
|
1057
|
+
let signedCount = 0;
|
|
1058
|
+
for (const field of NESTED_TARGETS) {
|
|
1059
|
+
const items = bom[field];
|
|
1060
|
+
if (items === undefined) {
|
|
1061
|
+
continue;
|
|
1062
|
+
}
|
|
1063
|
+
if (!Array.isArray(items)) {
|
|
1064
|
+
if (nestedOnly) {
|
|
1065
|
+
report.errors.push(`${field} is not an array of signed objects.`);
|
|
1066
|
+
}
|
|
1067
|
+
continue;
|
|
1068
|
+
}
|
|
1069
|
+
const unsigned = [];
|
|
1070
|
+
for (const [index, item] of items.entries()) {
|
|
1071
|
+
const label = nestedLabel(field, index, item);
|
|
1072
|
+
if (!isPlainObject(item) || item.signature === undefined) {
|
|
1073
|
+
unsigned.push(label);
|
|
1074
|
+
continue;
|
|
1075
|
+
}
|
|
1076
|
+
signedCount++;
|
|
1077
|
+
const identity = bomIdentityMembers(item);
|
|
1078
|
+
if (identity.length) {
|
|
1079
|
+
report.errors.push(
|
|
1080
|
+
`${label} carries ${identity.join(", ")}, which only the root of a BOM may hold.`,
|
|
1081
|
+
);
|
|
1082
|
+
}
|
|
1083
|
+
report.nested.push({
|
|
1084
|
+
label,
|
|
1085
|
+
...checkNode(item, trustedKeys, { requireAll }),
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
if (nestedOnly && unsigned.length) {
|
|
1089
|
+
report.errors.push(
|
|
1090
|
+
`${unsigned.length} of ${items.length} ${field} are not signed, starting with ${unsigned[0]}.`,
|
|
1091
|
+
);
|
|
267
1092
|
}
|
|
268
1093
|
}
|
|
269
|
-
|
|
1094
|
+
if (nestedOnly && allowNestedOnly) {
|
|
1095
|
+
if (!signedCount) {
|
|
1096
|
+
report.errors.push("The BOM has no signatures.");
|
|
1097
|
+
}
|
|
1098
|
+
const uncovered = Object.keys(bom).filter(
|
|
1099
|
+
(name) => !NESTED_TARGETS.includes(name),
|
|
1100
|
+
);
|
|
1101
|
+
report.notices.push(
|
|
1102
|
+
uncovered.length
|
|
1103
|
+
? `Without a root signature, no signature covers ${listMembers(uncovered)}, or which components, services, and annotations the BOM lists and in what order.`
|
|
1104
|
+
: "Without a root signature, no signature covers which components, services, and annotations the BOM lists or in what order.",
|
|
1105
|
+
);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
report.verified =
|
|
1109
|
+
!report.errors.length &&
|
|
1110
|
+
(report.root ? report.root.verified : allowNestedOnly && deep) &&
|
|
1111
|
+
report.nested.every((item) => item.verified);
|
|
1112
|
+
return report;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* Flattens a {@link verifyBomSignatures} report into the reasons it did not
|
|
1117
|
+
* verify: policy errors first, then each rejected root entry, then each
|
|
1118
|
+
* rejected nested entry prefixed with the element it belongs to.
|
|
1119
|
+
*
|
|
1120
|
+
* @param {Object} report - Result of verifyBomSignatures
|
|
1121
|
+
* @returns {string[]} - Reasons, empty when the report verified
|
|
1122
|
+
*/
|
|
1123
|
+
export function signatureReportReasons(report) {
|
|
1124
|
+
const reasons = [...report.errors];
|
|
1125
|
+
if (report.root && !report.root.verified) {
|
|
1126
|
+
reasons.push(...entryReasons(report.root));
|
|
270
1127
|
}
|
|
271
|
-
|
|
1128
|
+
for (const item of report.nested) {
|
|
1129
|
+
if (!item.verified) {
|
|
1130
|
+
reasons.push(
|
|
1131
|
+
...entryReasons(item).map((reason) => `${item.label}: ${reason}`),
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
return reasons;
|
|
272
1136
|
}
|
|
273
1137
|
|
|
274
1138
|
/**
|
|
275
|
-
* Verifies
|
|
276
|
-
*
|
|
1139
|
+
* Verifies a BOM's top-level signature, as well as nested components, services,
|
|
1140
|
+
* and annotations, with one key. Returns the root match only if the BOM verifies
|
|
1141
|
+
* as {@link verifyBomSignatures} defines with `deep` enabled.
|
|
277
1142
|
*
|
|
278
1143
|
* @param {Object} bom - CycloneDX BOM Object
|
|
279
|
-
* @param {string|crypto.KeyObject} publicKey -
|
|
280
|
-
* @
|
|
1144
|
+
* @param {string|Buffer|Object|crypto.KeyObject} publicKey - Verification key (see verifyNode)
|
|
1145
|
+
* @param {Object} [options] - Same options as verifyNode
|
|
1146
|
+
* @returns {boolean|Object} - Signature object if valid. False otherwise.
|
|
281
1147
|
*/
|
|
282
|
-
export function verifyBom(bom, publicKey) {
|
|
283
|
-
if (!bom
|
|
1148
|
+
export function verifyBom(bom, publicKey, options = {}) {
|
|
1149
|
+
if (!isPlainObject(bom) || !bom.signature) {
|
|
284
1150
|
return false;
|
|
285
1151
|
}
|
|
286
|
-
const
|
|
287
|
-
if (
|
|
288
|
-
return
|
|
1152
|
+
const report = verifyBomSignatures(bom, [publicKey]);
|
|
1153
|
+
if (report.verified) {
|
|
1154
|
+
return report.root.match;
|
|
289
1155
|
}
|
|
290
|
-
if (Array.isArray(
|
|
291
|
-
|
|
292
|
-
if (comp.signature && !verifyNode(comp, publicKey)) {
|
|
293
|
-
return false;
|
|
294
|
-
}
|
|
295
|
-
}
|
|
1156
|
+
if (Array.isArray(options.reasons)) {
|
|
1157
|
+
options.reasons.push(...signatureReportReasons(report));
|
|
296
1158
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
1159
|
+
return false;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/**
|
|
1163
|
+
* Checks every signature entry of a node against a set of trusted keys. An
|
|
1164
|
+
* entry is verified when one of the keys verifies that entry.
|
|
1165
|
+
*
|
|
1166
|
+
* `cdx-sign` uses this before appending a `signers` or `chain` entry, to check
|
|
1167
|
+
* that the content still carries the earlier signatures.
|
|
1168
|
+
*
|
|
1169
|
+
* @param {Object} node - The BOM or granular object whose signature is checked
|
|
1170
|
+
* @param {Array<string|Buffer|Object|crypto.KeyObject>} [keys] - Trusted verification keys (see verifyNode)
|
|
1171
|
+
* @returns {Array<{ index: number, algorithm: any, keyId: any, verified: boolean, reasons: string[] }>} - One result per signature entry; empty for an unsigned node
|
|
1172
|
+
*/
|
|
1173
|
+
export function checkSignatureEntries(node, keys = []) {
|
|
1174
|
+
if (!isPlainObject(node) || node.signature === undefined) {
|
|
1175
|
+
return [];
|
|
303
1176
|
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
1177
|
+
const trustedKeys = keys.map((key) => loadVerificationKey(key));
|
|
1178
|
+
const result = checkNode(node, trustedKeys);
|
|
1179
|
+
if (result.reasons.length) {
|
|
1180
|
+
return [
|
|
1181
|
+
{
|
|
1182
|
+
index: 0,
|
|
1183
|
+
algorithm: undefined,
|
|
1184
|
+
keyId: undefined,
|
|
1185
|
+
verified: false,
|
|
1186
|
+
reasons: result.reasons,
|
|
1187
|
+
},
|
|
1188
|
+
];
|
|
310
1189
|
}
|
|
311
|
-
return
|
|
1190
|
+
return result.entries;
|
|
312
1191
|
}
|