@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.
Files changed (311) hide show
  1. package/README.md +33 -14
  2. package/bin/cdxgen.js +310 -138
  3. package/bin/evinse.js +22 -0
  4. package/bin/repl.js +86 -0
  5. package/bin/sign.js +119 -15
  6. package/bin/tracebom.js +69 -5
  7. package/bin/validate.js +107 -19
  8. package/bin/verify.js +172 -63
  9. package/data/helpers/init.gradle +46 -21
  10. package/data/remediations.json +85 -0
  11. package/data/rules/dependency-sources.yaml +67 -0
  12. package/data/rules/scala-evidence.yaml +115 -0
  13. package/lib/audit/index.js +38 -6
  14. package/lib/cli/bomAssembly.js +105 -15
  15. package/lib/cli/cliOptions.js +93 -1
  16. package/lib/cli/index.js +156 -58
  17. package/lib/cli/jsBom.js +99 -53
  18. package/lib/cli/jvmBom.js +708 -147
  19. package/lib/cli/managedBom.js +415 -224
  20. package/lib/cli/nativeBom.js +264 -44
  21. package/lib/core/activity.js +12 -0
  22. package/lib/core/env.js +22 -15
  23. package/lib/core/fs.js +176 -7
  24. package/lib/core/httpClient.js +159 -24
  25. package/lib/core/metadataProperties.js +97 -0
  26. package/lib/core/pluginRun.js +184 -0
  27. package/lib/core/processTree.js +100 -0
  28. package/lib/core/projectConfig.js +31 -4
  29. package/lib/core/runState.js +39 -0
  30. package/lib/core/toml.js +72 -0
  31. package/lib/ecosystems/asarutils.js +4 -2
  32. package/lib/ecosystems/bunutils.js +274 -108
  33. package/lib/ecosystems/cargoCache.js +231 -8
  34. package/lib/ecosystems/caxa.js +434 -2
  35. package/lib/ecosystems/cmakeResolver.js +76 -21
  36. package/lib/ecosystems/cppBuildContext.js +631 -0
  37. package/lib/ecosystems/cppEvidence.js +399 -51
  38. package/lib/ecosystems/cppIncludeResolver.js +294 -0
  39. package/lib/ecosystems/denoutils.js +93 -66
  40. package/lib/ecosystems/ecosystems.js +1983 -793
  41. package/lib/ecosystems/golem.js +28 -12
  42. package/lib/ecosystems/gradleutils.js +356 -37
  43. package/lib/ecosystems/jsEvidence.js +104 -44
  44. package/lib/ecosystems/kosi.js +31 -7
  45. package/lib/ecosystems/npmutils.js +174 -9
  46. package/lib/ecosystems/parsers-dotnet.js +616 -159
  47. package/lib/ecosystems/parsers-elm.js +46 -28
  48. package/lib/ecosystems/parsers-gleam.js +3 -4
  49. package/lib/ecosystems/parsers-go.js +371 -70
  50. package/lib/ecosystems/parsers-js.js +263 -76
  51. package/lib/ecosystems/parsers-julia.js +2 -3
  52. package/lib/ecosystems/parsers-jvm.js +204 -12
  53. package/lib/ecosystems/parsers-misc.js +929 -216
  54. package/lib/ecosystems/parsers-python.js +167 -24
  55. package/lib/ecosystems/parsers-rust.js +28 -22
  56. package/lib/ecosystems/parsers-terraform.js +1573 -19
  57. package/lib/ecosystems/pythonutils.js +102 -6
  58. package/lib/ecosystems/remote/huggingface.js +3 -0
  59. package/lib/ecosystems/rubyutils.js +1492 -182
  60. package/lib/ecosystems/rusi.js +54 -10
  61. package/lib/ecosystems/sbtutils.js +243 -187
  62. package/lib/ecosystems/scalacliutils.js +342 -0
  63. package/lib/ecosystems/scalasem.js +2618 -0
  64. package/lib/ecosystems/terraformRegistry.js +288 -0
  65. package/lib/ecosystems/utils.js +2 -0
  66. package/lib/ecosystems/vendoredCode.js +156 -0
  67. package/lib/evinser/evinser.js +1008 -191
  68. package/lib/evinser/swiftsem.js +1914 -302
  69. package/lib/helpers/agenticToolutils.js +2 -2
  70. package/lib/helpers/bomSigner.js +1101 -222
  71. package/lib/inventory/aiCollector.js +4 -1
  72. package/lib/inventory/analyzer.js +110 -127
  73. package/lib/inventory/atomUtils.js +703 -43
  74. package/lib/inventory/auditCategories.js +7 -0
  75. package/lib/inventory/buildToolRateLimit.js +144 -0
  76. package/lib/inventory/cacheCommands.js +2 -1
  77. package/lib/inventory/cdxrs.js +103 -12
  78. package/lib/inventory/cmakeBuildDirs.js +254 -0
  79. package/lib/inventory/deps.js +501 -124
  80. package/lib/inventory/depsUtils.js +183 -11
  81. package/lib/inventory/display.js +4 -5
  82. package/lib/inventory/dosai.js +798 -57
  83. package/lib/inventory/dosaiParsers.js +310 -15
  84. package/lib/inventory/dynamic.js +90 -6
  85. package/lib/inventory/evidenceUtils.js +166 -113
  86. package/lib/inventory/fetchBatch.js +690 -55
  87. package/lib/inventory/fetchDiskCache.js +597 -0
  88. package/lib/inventory/forgeEnricher.js +14 -5
  89. package/lib/inventory/jvmLocalRepos.js +917 -0
  90. package/lib/inventory/mcpConfigParser.js +2 -3
  91. package/lib/inventory/parentSelection.js +146 -0
  92. package/lib/inventory/protobom.js +8 -1
  93. package/lib/inventory/purl.js +152 -3
  94. package/lib/inventory/rustFormulationParser.js +3 -4
  95. package/lib/inventory/sbomDocument.js +2 -70
  96. package/lib/inventory/scalaCoords.js +153 -0
  97. package/lib/inventory/source.js +45 -27
  98. package/lib/inventory/spdx.js +101 -0
  99. package/lib/inventory/traceRunner.js +295 -25
  100. package/lib/inventory/tracedCrypto.js +294 -0
  101. package/lib/managers/binary.js +115 -24
  102. package/lib/managers/docker.js +467 -46
  103. package/lib/managers/oci.js +63 -3
  104. package/lib/parsers/cmakeCache.js +69 -0
  105. package/lib/parsers/cmakeLists.js +466 -0
  106. package/lib/parsers/cmakePresets.js +400 -0
  107. package/lib/parsers/compileCommands.js +557 -0
  108. package/lib/parsers/largeJson.js +454 -0
  109. package/lib/parsers/npmrc.js +19 -1
  110. package/lib/parsers/strictJson.js +322 -0
  111. package/lib/parsers/terraformConfig.js +1073 -0
  112. package/lib/parsers/terraformSource.js +652 -0
  113. package/lib/server/server.js +23 -0
  114. package/lib/stages/postgen/introspection/markers.js +1 -1
  115. package/lib/stages/postgen/postgen.js +83 -16
  116. package/lib/stages/postgen/specVersionCompat.js +32 -0
  117. package/lib/validator/bomValidator.js +6 -16
  118. package/lib/validator/index.js +64 -11
  119. package/lib/validator/reporters/console.js +3 -0
  120. package/lib/validator/reporters/json.js +2 -0
  121. package/lib/validator/reporters/sarif.js +7 -0
  122. package/package.json +23 -23
  123. package/types/lib/audit/index.d.ts.map +1 -1
  124. package/types/lib/cli/bomAssembly.d.ts +2 -1
  125. package/types/lib/cli/bomAssembly.d.ts.map +1 -1
  126. package/types/lib/cli/cliOptions.d.ts +33 -1
  127. package/types/lib/cli/cliOptions.d.ts.map +1 -1
  128. package/types/lib/cli/index.d.ts.map +1 -1
  129. package/types/lib/cli/jsBom.d.ts.map +1 -1
  130. package/types/lib/cli/jvmBom.d.ts +24 -1
  131. package/types/lib/cli/jvmBom.d.ts.map +1 -1
  132. package/types/lib/cli/managedBom.d.ts +7 -4
  133. package/types/lib/cli/managedBom.d.ts.map +1 -1
  134. package/types/lib/cli/nativeBom.d.ts +3 -1
  135. package/types/lib/cli/nativeBom.d.ts.map +1 -1
  136. package/types/lib/core/activity.d.ts.map +1 -1
  137. package/types/lib/core/env.d.ts +9 -0
  138. package/types/lib/core/env.d.ts.map +1 -1
  139. package/types/lib/core/fs.d.ts +55 -0
  140. package/types/lib/core/fs.d.ts.map +1 -1
  141. package/types/lib/core/httpClient.d.ts +13 -8
  142. package/types/lib/core/httpClient.d.ts.map +1 -1
  143. package/types/lib/core/metadataProperties.d.ts +39 -0
  144. package/types/lib/core/metadataProperties.d.ts.map +1 -0
  145. package/types/lib/core/pluginRun.d.ts +97 -0
  146. package/types/lib/core/pluginRun.d.ts.map +1 -0
  147. package/types/lib/core/processTree.d.ts +36 -0
  148. package/types/lib/core/processTree.d.ts.map +1 -0
  149. package/types/lib/core/projectConfig.d.ts +6 -2
  150. package/types/lib/core/projectConfig.d.ts.map +1 -1
  151. package/types/lib/core/runState.d.ts +29 -0
  152. package/types/lib/core/runState.d.ts.map +1 -0
  153. package/types/lib/core/toml.d.ts +20 -0
  154. package/types/lib/core/toml.d.ts.map +1 -0
  155. package/types/lib/ecosystems/asarutils.d.ts.map +1 -1
  156. package/types/lib/ecosystems/bunutils.d.ts +12 -5
  157. package/types/lib/ecosystems/bunutils.d.ts.map +1 -1
  158. package/types/lib/ecosystems/cargoCache.d.ts +12 -0
  159. package/types/lib/ecosystems/cargoCache.d.ts.map +1 -1
  160. package/types/lib/ecosystems/caxa.d.ts +14 -0
  161. package/types/lib/ecosystems/caxa.d.ts.map +1 -1
  162. package/types/lib/ecosystems/cmakeResolver.d.ts +28 -3
  163. package/types/lib/ecosystems/cmakeResolver.d.ts.map +1 -1
  164. package/types/lib/ecosystems/cppBuildContext.d.ts +55 -0
  165. package/types/lib/ecosystems/cppBuildContext.d.ts.map +1 -0
  166. package/types/lib/ecosystems/cppEvidence.d.ts +17 -1
  167. package/types/lib/ecosystems/cppEvidence.d.ts.map +1 -1
  168. package/types/lib/ecosystems/cppIncludeResolver.d.ts +92 -0
  169. package/types/lib/ecosystems/cppIncludeResolver.d.ts.map +1 -0
  170. package/types/lib/ecosystems/denoutils.d.ts +9 -3
  171. package/types/lib/ecosystems/denoutils.d.ts.map +1 -1
  172. package/types/lib/ecosystems/ecosystems.d.ts +62 -13
  173. package/types/lib/ecosystems/ecosystems.d.ts.map +1 -1
  174. package/types/lib/ecosystems/golem.d.ts.map +1 -1
  175. package/types/lib/ecosystems/gradleutils.d.ts +70 -4
  176. package/types/lib/ecosystems/gradleutils.d.ts.map +1 -1
  177. package/types/lib/ecosystems/jsEvidence.d.ts +5 -2
  178. package/types/lib/ecosystems/jsEvidence.d.ts.map +1 -1
  179. package/types/lib/ecosystems/kosi.d.ts +13 -0
  180. package/types/lib/ecosystems/kosi.d.ts.map +1 -1
  181. package/types/lib/ecosystems/npmutils.d.ts +57 -0
  182. package/types/lib/ecosystems/npmutils.d.ts.map +1 -1
  183. package/types/lib/ecosystems/parsers-dotnet.d.ts +118 -4
  184. package/types/lib/ecosystems/parsers-dotnet.d.ts.map +1 -1
  185. package/types/lib/ecosystems/parsers-elm.d.ts.map +1 -1
  186. package/types/lib/ecosystems/parsers-gleam.d.ts.map +1 -1
  187. package/types/lib/ecosystems/parsers-go.d.ts +54 -2
  188. package/types/lib/ecosystems/parsers-go.d.ts.map +1 -1
  189. package/types/lib/ecosystems/parsers-js.d.ts +16 -5
  190. package/types/lib/ecosystems/parsers-js.d.ts.map +1 -1
  191. package/types/lib/ecosystems/parsers-julia.d.ts.map +1 -1
  192. package/types/lib/ecosystems/parsers-jvm.d.ts +46 -1
  193. package/types/lib/ecosystems/parsers-jvm.d.ts.map +1 -1
  194. package/types/lib/ecosystems/parsers-misc.d.ts +70 -4
  195. package/types/lib/ecosystems/parsers-misc.d.ts.map +1 -1
  196. package/types/lib/ecosystems/parsers-python.d.ts +53 -3
  197. package/types/lib/ecosystems/parsers-python.d.ts.map +1 -1
  198. package/types/lib/ecosystems/parsers-rust.d.ts.map +1 -1
  199. package/types/lib/ecosystems/parsers-terraform.d.ts +38 -21
  200. package/types/lib/ecosystems/parsers-terraform.d.ts.map +1 -1
  201. package/types/lib/ecosystems/pythonutils.d.ts +27 -2
  202. package/types/lib/ecosystems/pythonutils.d.ts.map +1 -1
  203. package/types/lib/ecosystems/remote/huggingface.d.ts.map +1 -1
  204. package/types/lib/ecosystems/rubyutils.d.ts +164 -3
  205. package/types/lib/ecosystems/rubyutils.d.ts.map +1 -1
  206. package/types/lib/ecosystems/rusi.d.ts +13 -0
  207. package/types/lib/ecosystems/rusi.d.ts.map +1 -1
  208. package/types/lib/ecosystems/sbtutils.d.ts +88 -2
  209. package/types/lib/ecosystems/sbtutils.d.ts.map +1 -1
  210. package/types/lib/ecosystems/scalacliutils.d.ts +56 -0
  211. package/types/lib/ecosystems/scalacliutils.d.ts.map +1 -0
  212. package/types/lib/ecosystems/scalasem.d.ts +311 -0
  213. package/types/lib/ecosystems/scalasem.d.ts.map +1 -0
  214. package/types/lib/ecosystems/terraformRegistry.d.ts +9 -0
  215. package/types/lib/ecosystems/terraformRegistry.d.ts.map +1 -0
  216. package/types/lib/ecosystems/utils.d.ts +2 -2
  217. package/types/lib/ecosystems/utils.d.ts.map +1 -1
  218. package/types/lib/ecosystems/vendoredCode.d.ts +32 -0
  219. package/types/lib/ecosystems/vendoredCode.d.ts.map +1 -0
  220. package/types/lib/evinser/evinser.d.ts +132 -15
  221. package/types/lib/evinser/evinser.d.ts.map +1 -1
  222. package/types/lib/evinser/swiftsem.d.ts +280 -15
  223. package/types/lib/evinser/swiftsem.d.ts.map +1 -1
  224. package/types/lib/helpers/bomSigner.d.ts +182 -13
  225. package/types/lib/helpers/bomSigner.d.ts.map +1 -1
  226. package/types/lib/inventory/aiCollector.d.ts.map +1 -1
  227. package/types/lib/inventory/analyzer.d.ts.map +1 -1
  228. package/types/lib/inventory/atomUtils.d.ts +219 -5
  229. package/types/lib/inventory/atomUtils.d.ts.map +1 -1
  230. package/types/lib/inventory/auditCategories.d.ts +3 -0
  231. package/types/lib/inventory/auditCategories.d.ts.map +1 -1
  232. package/types/lib/inventory/buildToolRateLimit.d.ts +38 -0
  233. package/types/lib/inventory/buildToolRateLimit.d.ts.map +1 -0
  234. package/types/lib/inventory/cacheCommands.d.ts +2 -1
  235. package/types/lib/inventory/cacheCommands.d.ts.map +1 -1
  236. package/types/lib/inventory/cdxrs.d.ts +8 -3
  237. package/types/lib/inventory/cdxrs.d.ts.map +1 -1
  238. package/types/lib/inventory/cmakeBuildDirs.d.ts +58 -0
  239. package/types/lib/inventory/cmakeBuildDirs.d.ts.map +1 -0
  240. package/types/lib/inventory/deps.d.ts +59 -1
  241. package/types/lib/inventory/deps.d.ts.map +1 -1
  242. package/types/lib/inventory/depsUtils.d.ts +15 -0
  243. package/types/lib/inventory/depsUtils.d.ts.map +1 -1
  244. package/types/lib/inventory/display.d.ts.map +1 -1
  245. package/types/lib/inventory/dosai.d.ts +121 -9
  246. package/types/lib/inventory/dosai.d.ts.map +1 -1
  247. package/types/lib/inventory/dosaiParsers.d.ts +53 -7
  248. package/types/lib/inventory/dosaiParsers.d.ts.map +1 -1
  249. package/types/lib/inventory/dynamic.d.ts +10 -0
  250. package/types/lib/inventory/dynamic.d.ts.map +1 -1
  251. package/types/lib/inventory/evidenceUtils.d.ts +19 -4
  252. package/types/lib/inventory/evidenceUtils.d.ts.map +1 -1
  253. package/types/lib/inventory/fetchBatch.d.ts +83 -14
  254. package/types/lib/inventory/fetchBatch.d.ts.map +1 -1
  255. package/types/lib/inventory/fetchDiskCache.d.ts +169 -0
  256. package/types/lib/inventory/fetchDiskCache.d.ts.map +1 -0
  257. package/types/lib/inventory/forgeEnricher.d.ts.map +1 -1
  258. package/types/lib/inventory/jvmLocalRepos.d.ts +178 -0
  259. package/types/lib/inventory/jvmLocalRepos.d.ts.map +1 -0
  260. package/types/lib/inventory/mcpConfigParser.d.ts.map +1 -1
  261. package/types/lib/inventory/parentSelection.d.ts +34 -0
  262. package/types/lib/inventory/parentSelection.d.ts.map +1 -0
  263. package/types/lib/inventory/plugins.d.ts.map +1 -1
  264. package/types/lib/inventory/protobom.d.ts.map +1 -1
  265. package/types/lib/inventory/purl.d.ts +71 -2
  266. package/types/lib/inventory/purl.d.ts.map +1 -1
  267. package/types/lib/inventory/rustFormulationParser.d.ts.map +1 -1
  268. package/types/lib/inventory/sbomDocument.d.ts.map +1 -1
  269. package/types/lib/inventory/scalaCoords.d.ts +80 -0
  270. package/types/lib/inventory/scalaCoords.d.ts.map +1 -0
  271. package/types/lib/inventory/source.d.ts.map +1 -1
  272. package/types/lib/inventory/spdx.d.ts +9 -0
  273. package/types/lib/inventory/spdx.d.ts.map +1 -1
  274. package/types/lib/inventory/traceRunner.d.ts +59 -1
  275. package/types/lib/inventory/traceRunner.d.ts.map +1 -1
  276. package/types/lib/inventory/tracedCrypto.d.ts +42 -0
  277. package/types/lib/inventory/tracedCrypto.d.ts.map +1 -0
  278. package/types/lib/managers/binary.d.ts +30 -1
  279. package/types/lib/managers/binary.d.ts.map +1 -1
  280. package/types/lib/managers/docker.d.ts +29 -0
  281. package/types/lib/managers/docker.d.ts.map +1 -1
  282. package/types/lib/managers/oci.d.ts +17 -1
  283. package/types/lib/managers/oci.d.ts.map +1 -1
  284. package/types/lib/parsers/cmakeCache.d.ts +16 -0
  285. package/types/lib/parsers/cmakeCache.d.ts.map +1 -1
  286. package/types/lib/parsers/cmakeLists.d.ts +77 -0
  287. package/types/lib/parsers/cmakeLists.d.ts.map +1 -0
  288. package/types/lib/parsers/cmakePresets.d.ts +78 -0
  289. package/types/lib/parsers/cmakePresets.d.ts.map +1 -0
  290. package/types/lib/parsers/compileCommands.d.ts +115 -0
  291. package/types/lib/parsers/compileCommands.d.ts.map +1 -0
  292. package/types/lib/parsers/largeJson.d.ts +52 -0
  293. package/types/lib/parsers/largeJson.d.ts.map +1 -0
  294. package/types/lib/parsers/npmrc.d.ts +5 -0
  295. package/types/lib/parsers/npmrc.d.ts.map +1 -1
  296. package/types/lib/parsers/strictJson.d.ts +46 -0
  297. package/types/lib/parsers/strictJson.d.ts.map +1 -0
  298. package/types/lib/parsers/terraformConfig.d.ts +70 -0
  299. package/types/lib/parsers/terraformConfig.d.ts.map +1 -0
  300. package/types/lib/parsers/terraformSource.d.ts +66 -0
  301. package/types/lib/parsers/terraformSource.d.ts.map +1 -0
  302. package/types/lib/server/server.d.ts.map +1 -1
  303. package/types/lib/stages/postgen/postgen.d.ts.map +1 -1
  304. package/types/lib/stages/postgen/specVersionCompat.d.ts.map +1 -1
  305. package/types/lib/validator/bomValidator.d.ts.map +1 -1
  306. package/types/lib/validator/index.d.ts +18 -1
  307. package/types/lib/validator/index.d.ts.map +1 -1
  308. package/types/lib/validator/reporters/console.d.ts.map +1 -1
  309. package/types/lib/validator/reporters/json.d.ts.map +1 -1
  310. package/types/lib/validator/reporters/sarif.d.ts.map +1 -1
  311. package/lib/third-party/arborist/snapshots/calc-dep-flags.test.cjs +0 -462
@@ -1,137 +1,844 @@
1
1
  import crypto from "node:crypto";
2
2
 
3
3
  /**
4
- * Lightweight, deterministic JSON Canonicalizer (similar to RFC 8785).
5
- * Required by JSF to ensure the signature payload remains identical across systems.
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 keys = Object.keys(value).sort();
18
- let str = "{";
19
- for (let i = 0; i < keys.length; i++) {
20
- if (i > 0) str += ",";
21
- str += `${JSON.stringify(keys[i])}:${canonicalize(value[keys[i]])}`;
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
- str += "}";
24
- return str;
183
+ return `{${members.join(",")}}`;
25
184
  }
26
185
 
27
186
  /**
28
- * Creates a JSF-compliant signature block.
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} payload - The object to sign (e.g., BOM, component)
31
- * @param {string|Buffer|crypto.KeyObject} privateKey - The signing key
32
- * @param {string} alg - JSF algorithm identifier
33
- * @param {Object} [publicKeyJwk] - Optional JWK representation of the public key
34
- * @param {string} keyId - Key ID
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
- * @returns {Object} - JSF signature block
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 createSignatureBlock(
39
- payload,
40
- privateKey,
41
- alg,
42
- publicKeyJwk = null,
43
- keyId = null,
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
- if (keyId) {
64
- block.keyId = keyId;
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
- return block;
67
- }
68
-
69
- // Handle Asymmetric Algorithms
70
- if (alg.startsWith("RS")) {
71
- hashAlg = alg.replace("RS", "SHA");
72
- } else if (alg.startsWith("PS")) {
73
- hashAlg = alg.replace("PS", "SHA");
74
- signOptions.padding = crypto.constants.RSA_PKCS1_PSS_PADDING;
75
- signOptions.saltLength = crypto.constants.RSA_PSS_SALTLEN_AUTO;
76
- } else if (alg.startsWith("ES")) {
77
- hashAlg = alg.replace("ES", "SHA");
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
- signOptions.dsaEncoding = "ieee-p1363";
80
- } else if (alg === "Ed25519" || alg === "Ed448") {
81
- // Native EdDSA algorithms do not require a separate hash algorithm definition
82
- hashAlg = null;
83
- } else {
84
- throw new Error(`Unsupported JSF algorithm: ${alg}`);
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 sigBuffer = crypto.sign(
87
- hashAlg,
88
- Buffer.from(canonicalData, "utf8"),
89
- signOptions,
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
- const block = { algorithm: alg, value: sigBuffer.toString("base64url") };
92
- if (publicKeyJwk) {
93
- block.publicKey = publicKeyJwk;
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 (keyId) {
96
- block.keyId = keyId;
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
- return block;
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
- * Appends or replaces a signature on a target object based on the configured mode.
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 addSignature(target, sigBlock, mode) {
105
- if (!target.signature) {
106
- target.signature = sigBlock;
107
- return;
108
- }
109
- if (mode === "chain") {
110
- if (target.signature.chain) {
111
- target.signature.chain.push(sigBlock);
112
- } else if (target.signature.signers) {
113
- throw new Error("Cannot mix signature chains and multi-signers.");
114
- } else {
115
- target.signature = { chain: [target.signature, sigBlock] };
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
- } else if (mode === "signers") {
118
- if (target.signature.signers) {
119
- target.signature.signers.push(sigBlock);
120
- } else if (target.signature.chain) {
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
- * Recursively applies signatures to the BOM and its granular components.
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 { privateKey, algorithm, mode, ... }
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", // Supports: 'replace', 'chain', 'signers'
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 (signComponents && Array.isArray(bomJson.components)) {
153
- for (const comp of bomJson.components) {
154
- addSignature(
155
- comp,
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 (signServices && Array.isArray(bomJson.services)) {
162
- for (const svc of bomJson.services) {
163
- addSignature(
164
- svc,
165
- createSignatureBlock(svc, privateKey, algorithm, publicKeyJwk, keyId),
166
- mode,
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
- if (signAnnotations && Array.isArray(bomJson.annotations)) {
171
- for (const ann of bomJson.annotations) {
172
- addSignature(
173
- ann,
174
- createSignatureBlock(ann, privateKey, algorithm, publicKeyJwk, keyId),
175
- mode,
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
- addSignature(
180
- bomJson,
181
- createSignatureBlock(bomJson, privateKey, algorithm, publicKeyJwk, keyId),
182
- mode,
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
- * Validates a single JSF signature block against the payload.
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
- * @param {Object} payload - The payload to verify
191
- * @param {string|crypto.KeyObject} publicKey - The public key corresponding to the signature
192
- * @param {Object} sigBlock Signature
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
- * @returns {boolean|Object} - Signature block if signature is valid. False otherwise.
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 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.`);
954
+ export function verifyNode(node, publicKey, options = {}) {
955
+ if (!isPlainObject(node) || node.signature === undefined) {
228
956
  return false;
229
957
  }
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;
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 integrity of a specific element node (e.g., BOM root, Component, Service, Annotation).
242
- * Resolves standard JSF signatures, multisignature (signers), and chains.
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
- * @param {Object} node - The BOM or granular object to verify
245
- * @param {string|crypto.KeyObject} publicKey - The public key corresponding to the signature
246
- * @returns {boolean|Object} - Signature block if signature is valid. False otherwise.
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 verifyNode(node, publicKey) {
249
- if (!node?.signature) {
250
- return false;
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 sigTarget = node.signature;
253
- if (sigTarget.signers) {
254
- for (const sig of sigTarget.signers) {
255
- const match = verifySignatureBlock(node, publicKey, sig);
256
- if (match) {
257
- return match;
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
- return false;
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 (sigTarget.chain) {
263
- for (const sig of sigTarget.chain) {
264
- const match = verifySignatureBlock(node, publicKey, sig);
265
- if (match) {
266
- return match;
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
- return false;
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
- return verifySignatureBlock(node, publicKey, sigTarget);
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 the integrity of a BOM's top-level signature, as well as nested components, services, and annotations.
276
- * Returns true only if the root signature is valid AND all signed nested elements are valid.
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 - The public key corresponding to the signature
280
- * @returns {boolean|Object} - Signature block if signature is valid. False otherwise.
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?.signature) {
1148
+ export function verifyBom(bom, publicKey, options = {}) {
1149
+ if (!isPlainObject(bom) || !bom.signature) {
284
1150
  return false;
285
1151
  }
286
- const rootMatch = verifyNode(bom, publicKey);
287
- if (!rootMatch) {
288
- return false;
1152
+ const report = verifyBomSignatures(bom, [publicKey]);
1153
+ if (report.verified) {
1154
+ return report.root.match;
289
1155
  }
290
- if (Array.isArray(bom.components)) {
291
- for (const comp of bom.components) {
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
- if (Array.isArray(bom.services)) {
298
- for (const svc of bom.services) {
299
- if (svc.signature && !verifyNode(svc, publicKey)) {
300
- return false;
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
- if (Array.isArray(bom.annotations)) {
305
- for (const ann of bom.annotations) {
306
- if (ann.signature && !verifyNode(ann, publicKey)) {
307
- return false;
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 rootMatch;
1190
+ return result.entries;
312
1191
  }