@freedomofpress/sigstore-browser 0.1.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 (193) hide show
  1. package/.github/workflows/conformance.yml +29 -0
  2. package/.github/workflows/publish.yml +45 -0
  3. package/README.md +10 -0
  4. package/dist/asn1/error.d.ts +5 -0
  5. package/dist/asn1/error.d.ts.map +1 -0
  6. package/dist/asn1/error.js +20 -0
  7. package/dist/asn1/error.js.map +1 -0
  8. package/dist/asn1/index.d.ts +2 -0
  9. package/dist/asn1/index.d.ts.map +1 -0
  10. package/dist/asn1/index.js +17 -0
  11. package/dist/asn1/index.js.map +1 -0
  12. package/dist/asn1/length.d.ts +4 -0
  13. package/dist/asn1/length.d.ts.map +1 -0
  14. package/dist/asn1/length.js +59 -0
  15. package/dist/asn1/length.js.map +1 -0
  16. package/dist/asn1/obj.d.ts +15 -0
  17. package/dist/asn1/obj.d.ts.map +1 -0
  18. package/dist/asn1/obj.js +153 -0
  19. package/dist/asn1/obj.js.map +1 -0
  20. package/dist/asn1/parse.d.ts +7 -0
  21. package/dist/asn1/parse.d.ts.map +1 -0
  22. package/dist/asn1/parse.js +118 -0
  23. package/dist/asn1/parse.js.map +1 -0
  24. package/dist/asn1/tag.d.ts +17 -0
  25. package/dist/asn1/tag.d.ts.map +1 -0
  26. package/dist/asn1/tag.js +86 -0
  27. package/dist/asn1/tag.js.map +1 -0
  28. package/dist/bundle.d.ts +87 -0
  29. package/dist/bundle.d.ts.map +1 -0
  30. package/dist/bundle.js +2 -0
  31. package/dist/bundle.js.map +1 -0
  32. package/dist/canonicalize.d.ts +2 -0
  33. package/dist/canonicalize.d.ts.map +1 -0
  34. package/dist/canonicalize.js +64 -0
  35. package/dist/canonicalize.js.map +1 -0
  36. package/dist/cli.d.ts +3 -0
  37. package/dist/cli.d.ts.map +1 -0
  38. package/dist/cli.js +217 -0
  39. package/dist/cli.js.map +1 -0
  40. package/dist/crypto.d.ts +5 -0
  41. package/dist/crypto.d.ts.map +1 -0
  42. package/dist/crypto.js +260 -0
  43. package/dist/crypto.js.map +1 -0
  44. package/dist/dsse.d.ts +2 -0
  45. package/dist/dsse.d.ts.map +1 -0
  46. package/dist/dsse.js +42 -0
  47. package/dist/dsse.js.map +1 -0
  48. package/dist/encoding.d.ts +17 -0
  49. package/dist/encoding.d.ts.map +1 -0
  50. package/dist/encoding.js +112 -0
  51. package/dist/encoding.js.map +1 -0
  52. package/dist/errors.d.ts +39 -0
  53. package/dist/errors.d.ts.map +1 -0
  54. package/dist/errors.js +79 -0
  55. package/dist/errors.js.map +1 -0
  56. package/dist/index.d.ts +8 -0
  57. package/dist/index.d.ts.map +1 -0
  58. package/dist/index.js +5 -0
  59. package/dist/index.js.map +1 -0
  60. package/dist/interfaces.d.ts +104 -0
  61. package/dist/interfaces.d.ts.map +1 -0
  62. package/dist/interfaces.js +26 -0
  63. package/dist/interfaces.js.map +1 -0
  64. package/dist/oid.d.ts +5 -0
  65. package/dist/oid.d.ts.map +1 -0
  66. package/dist/oid.js +25 -0
  67. package/dist/oid.js.map +1 -0
  68. package/dist/pem.d.ts +3 -0
  69. package/dist/pem.d.ts.map +1 -0
  70. package/dist/pem.js +41 -0
  71. package/dist/pem.js.map +1 -0
  72. package/dist/rfc3161/error.d.ts +3 -0
  73. package/dist/rfc3161/error.d.ts.map +1 -0
  74. package/dist/rfc3161/error.js +18 -0
  75. package/dist/rfc3161/error.js.map +1 -0
  76. package/dist/rfc3161/index.d.ts +2 -0
  77. package/dist/rfc3161/index.d.ts.map +1 -0
  78. package/dist/rfc3161/index.js +17 -0
  79. package/dist/rfc3161/index.js.map +1 -0
  80. package/dist/rfc3161/timestamp.d.ts +36 -0
  81. package/dist/rfc3161/timestamp.d.ts.map +1 -0
  82. package/dist/rfc3161/timestamp.js +203 -0
  83. package/dist/rfc3161/timestamp.js.map +1 -0
  84. package/dist/rfc3161/tstinfo.d.ts +13 -0
  85. package/dist/rfc3161/tstinfo.d.ts.map +1 -0
  86. package/dist/rfc3161/tstinfo.js +47 -0
  87. package/dist/rfc3161/tstinfo.js.map +1 -0
  88. package/dist/sigstore.d.ts +33 -0
  89. package/dist/sigstore.d.ts.map +1 -0
  90. package/dist/sigstore.js +531 -0
  91. package/dist/sigstore.js.map +1 -0
  92. package/dist/stream.d.ts +22 -0
  93. package/dist/stream.d.ts.map +1 -0
  94. package/dist/stream.js +114 -0
  95. package/dist/stream.js.map +1 -0
  96. package/dist/timestamp/tsa.d.ts +24 -0
  97. package/dist/timestamp/tsa.d.ts.map +1 -0
  98. package/dist/timestamp/tsa.js +150 -0
  99. package/dist/timestamp/tsa.js.map +1 -0
  100. package/dist/tlog/body.d.ts +8 -0
  101. package/dist/tlog/body.d.ts.map +1 -0
  102. package/dist/tlog/body.js +45 -0
  103. package/dist/tlog/body.js.map +1 -0
  104. package/dist/tlog/checkpoint.d.ts +23 -0
  105. package/dist/tlog/checkpoint.d.ts.map +1 -0
  106. package/dist/tlog/checkpoint.js +187 -0
  107. package/dist/tlog/checkpoint.js.map +1 -0
  108. package/dist/tlog/dsse.d.ts +4 -0
  109. package/dist/tlog/dsse.d.ts.map +1 -0
  110. package/dist/tlog/dsse.js +85 -0
  111. package/dist/tlog/dsse.js.map +1 -0
  112. package/dist/tlog/hashedrekord.d.ts +4 -0
  113. package/dist/tlog/hashedrekord.d.ts.map +1 -0
  114. package/dist/tlog/hashedrekord.js +63 -0
  115. package/dist/tlog/hashedrekord.js.map +1 -0
  116. package/dist/tlog/intoto.d.ts +4 -0
  117. package/dist/tlog/intoto.d.ts.map +1 -0
  118. package/dist/tlog/intoto.js +50 -0
  119. package/dist/tlog/intoto.js.map +1 -0
  120. package/dist/tlog/merkle.d.ts +3 -0
  121. package/dist/tlog/merkle.d.ts.map +1 -0
  122. package/dist/tlog/merkle.js +112 -0
  123. package/dist/tlog/merkle.js.map +1 -0
  124. package/dist/trust/tuf-root.json +1 -0
  125. package/dist/trust/tuf.d.ts +95 -0
  126. package/dist/trust/tuf.d.ts.map +1 -0
  127. package/dist/trust/tuf.js +150 -0
  128. package/dist/trust/tuf.js.map +1 -0
  129. package/dist/x509/cert.d.ts +52 -0
  130. package/dist/x509/cert.d.ts.map +1 -0
  131. package/dist/x509/cert.js +256 -0
  132. package/dist/x509/cert.js.map +1 -0
  133. package/dist/x509/chain.d.ts +20 -0
  134. package/dist/x509/chain.d.ts.map +1 -0
  135. package/dist/x509/chain.js +141 -0
  136. package/dist/x509/chain.js.map +1 -0
  137. package/dist/x509/ext.d.ts +48 -0
  138. package/dist/x509/ext.d.ts.map +1 -0
  139. package/dist/x509/ext.js +164 -0
  140. package/dist/x509/ext.js.map +1 -0
  141. package/dist/x509/index.d.ts +4 -0
  142. package/dist/x509/index.d.ts.map +1 -0
  143. package/dist/x509/index.js +19 -0
  144. package/dist/x509/index.js.map +1 -0
  145. package/dist/x509/sct.d.ts +25 -0
  146. package/dist/x509/sct.d.ts.map +1 -0
  147. package/dist/x509/sct.js +123 -0
  148. package/dist/x509/sct.js.map +1 -0
  149. package/eslint.config.mjs +31 -0
  150. package/package.json +39 -0
  151. package/src/asn1/error.ts +18 -0
  152. package/src/asn1/index.ts +16 -0
  153. package/src/asn1/length.ts +71 -0
  154. package/src/asn1/obj.ts +184 -0
  155. package/src/asn1/parse.ts +146 -0
  156. package/src/asn1/tag.ts +105 -0
  157. package/src/bundle.ts +99 -0
  158. package/src/canonicalize.ts +61 -0
  159. package/src/cli.ts +285 -0
  160. package/src/crypto.ts +310 -0
  161. package/src/dsse.ts +49 -0
  162. package/src/encoding.ts +141 -0
  163. package/src/errors.ts +82 -0
  164. package/src/index.browser.test.ts +219 -0
  165. package/src/index.ts +14 -0
  166. package/src/interfaces.ts +118 -0
  167. package/src/oid.ts +28 -0
  168. package/src/pem.ts +48 -0
  169. package/src/rfc3161/error.ts +16 -0
  170. package/src/rfc3161/index.ts +17 -0
  171. package/src/rfc3161/timestamp.ts +270 -0
  172. package/src/rfc3161/tstinfo.ts +80 -0
  173. package/src/sigstore.ts +728 -0
  174. package/src/stream.ts +141 -0
  175. package/src/timestamp/tsa.ts +205 -0
  176. package/src/tlog/body.ts +65 -0
  177. package/src/tlog/checkpoint.ts +248 -0
  178. package/src/tlog/dsse.ts +147 -0
  179. package/src/tlog/hashedrekord.ts +130 -0
  180. package/src/tlog/intoto.ts +102 -0
  181. package/src/tlog/merkle.ts +152 -0
  182. package/src/trust/README.md +38 -0
  183. package/src/trust/tuf-root.json +1 -0
  184. package/src/trust/tuf.ts +223 -0
  185. package/src/types/tuf-browser.d.ts +13 -0
  186. package/src/x509/cert.ts +319 -0
  187. package/src/x509/chain.ts +212 -0
  188. package/src/x509/ext.ts +212 -0
  189. package/src/x509/index.ts +19 -0
  190. package/src/x509/sct.ts +154 -0
  191. package/tsconfig.json +18 -0
  192. package/vite.config.playwright.ts +26 -0
  193. package/vite.config.ts +18 -0
package/src/cli.ts ADDED
@@ -0,0 +1,285 @@
1
+ #!/usr/bin/env node
2
+ import { createHash, webcrypto } from "node:crypto";
3
+ import { access, readFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+
6
+ import { SigstoreBundle } from "./bundle.js";
7
+ import { base64ToUint8Array, hexToUint8Array, Uint8ArrayToHex, Uint8ArrayToString } from "./encoding.js";
8
+ import { TrustedRoot } from "./interfaces.js";
9
+ import { SigstoreVerifier } from "./sigstore.js";
10
+ import { TrustedRootProvider } from "./trust/tuf.js";
11
+
12
+ // Ensure the global Web Crypto implementation is available when running under Node.js
13
+ if (typeof globalThis.crypto === "undefined") {
14
+ globalThis.crypto = webcrypto as unknown as Crypto;
15
+ }
16
+
17
+ interface CLIOptions {
18
+ bundlePath: string;
19
+ certificateIdentity: string;
20
+ certificateOidcIssuer: string;
21
+ trustedRootPath: string | undefined;
22
+ artifactInput: string;
23
+ }
24
+
25
+ type ArtifactInput =
26
+ | { type: "file"; path: string; data: Uint8Array; digestHex: string }
27
+ | { type: "digest"; digestHex: string };
28
+
29
+ const USAGE = `Usage: sigstore-browser verify-bundle [--staging] --bundle FILE --certificate-identity IDENTITY --certificate-oidc-issuer URL [--trusted-root FILE] FILE_OR_DIGEST`;
30
+
31
+ function printUsage(): void {
32
+ console.error(USAGE);
33
+ }
34
+
35
+ function parseArgs(argv: string[]): CLIOptions {
36
+ const options: Partial<CLIOptions> = {};
37
+ let command: string | undefined;
38
+ let artifactInput: string | undefined;
39
+
40
+ for (let i = 0; i < argv.length; i++) {
41
+ const current = argv[i];
42
+
43
+ if (current === "--help" || current === "-h") {
44
+ printUsage();
45
+ process.exit(0);
46
+ }
47
+
48
+ if (current.startsWith("--")) {
49
+ const [name, providedValue] = current.split("=", 2);
50
+
51
+ switch (name) {
52
+ case "--bundle": {
53
+ const value = providedValue ?? argv[++i];
54
+ if (!value) {
55
+ throw new Error("Missing value for --bundle");
56
+ }
57
+ options.bundlePath = value;
58
+ break;
59
+ }
60
+ case "--certificate-identity": {
61
+ const value = providedValue ?? argv[++i];
62
+ if (!value) {
63
+ throw new Error("Missing value for --certificate-identity");
64
+ }
65
+ options.certificateIdentity = value;
66
+ break;
67
+ }
68
+ case "--certificate-oidc-issuer": {
69
+ const value = providedValue ?? argv[++i];
70
+ if (!value) {
71
+ throw new Error("Missing value for --certificate-oidc-issuer");
72
+ }
73
+ options.certificateOidcIssuer = value;
74
+ break;
75
+ }
76
+ case "--trusted-root": {
77
+ const value = providedValue ?? argv[++i];
78
+ if (!value) {
79
+ throw new Error("Missing value for --trusted-root");
80
+ }
81
+ options.trustedRootPath = value;
82
+ break;
83
+ }
84
+ default:
85
+ throw new Error(`Unknown option: ${name}`);
86
+ }
87
+
88
+ continue;
89
+ }
90
+
91
+ if (!command) {
92
+ command = current;
93
+ continue;
94
+ }
95
+
96
+ if (!artifactInput) {
97
+ artifactInput = current;
98
+ continue;
99
+ }
100
+
101
+ throw new Error(`Unexpected argument: ${current}`);
102
+ }
103
+
104
+ if (command !== "verify-bundle") {
105
+ throw new Error("No command provided. Expected 'verify-bundle'.");
106
+ }
107
+
108
+ if (!artifactInput) {
109
+ throw new Error("Missing FILE_OR_DIGEST argument.");
110
+ }
111
+
112
+ if (!options.bundlePath) {
113
+ throw new Error("Missing required option --bundle");
114
+ }
115
+
116
+ if (!options.certificateIdentity) {
117
+ throw new Error("Missing required option --certificate-identity");
118
+ }
119
+
120
+ if (!options.certificateOidcIssuer) {
121
+ throw new Error("Missing required option --certificate-oidc-issuer");
122
+ }
123
+
124
+ return {
125
+ bundlePath: options.bundlePath,
126
+ certificateIdentity: options.certificateIdentity,
127
+ certificateOidcIssuer: options.certificateOidcIssuer,
128
+ trustedRootPath: options.trustedRootPath,
129
+ artifactInput,
130
+ };
131
+ }
132
+
133
+ async function fileExists(filePath: string): Promise<boolean> {
134
+ try {
135
+ await access(filePath);
136
+ return true;
137
+ } catch {
138
+ return false;
139
+ }
140
+ }
141
+
142
+ async function resolveArtifact(input: string): Promise<ArtifactInput> {
143
+ const digestPrefix = "sha256:";
144
+
145
+ if (
146
+ input.startsWith(digestPrefix) &&
147
+ input.length === digestPrefix.length + 64 &&
148
+ /^[0-9a-fA-F]+$/.test(input.slice(digestPrefix.length)) &&
149
+ !(await fileExists(input))
150
+ ) {
151
+ return {
152
+ type: "digest",
153
+ digestHex: input.slice(digestPrefix.length).toLowerCase(),
154
+ };
155
+ }
156
+
157
+ const resolvedPath = path.resolve(input);
158
+ const artifactBuffer = await readFile(resolvedPath);
159
+ const digestHex = createHash("sha256").update(artifactBuffer).digest("hex");
160
+
161
+ return {
162
+ type: "file",
163
+ path: resolvedPath,
164
+ data: new Uint8Array(artifactBuffer),
165
+ digestHex,
166
+ };
167
+ }
168
+
169
+ async function loadTrustedRoot(
170
+ pathInput: string | undefined,
171
+ ): Promise<TrustedRoot> {
172
+ if (!pathInput) {
173
+ // Use TUF to fetch the latest trusted root (matches sigstore-js behavior)
174
+ const tufProvider = new TrustedRootProvider({
175
+ metadataUrl: "https://tuf-repo-cdn.sigstore.dev/",
176
+ targetBaseUrl: "https://tuf-repo-cdn.sigstore.dev/targets/",
177
+ });
178
+ return await tufProvider.getTrustedRoot();
179
+ }
180
+ const resolvedPath = path.resolve(pathInput);
181
+ const raw = await readFile(resolvedPath, "utf8");
182
+ return JSON.parse(raw) as TrustedRoot;
183
+ }
184
+
185
+ async function loadBundle(bundlePath: string): Promise<SigstoreBundle> {
186
+ const resolved = path.resolve(bundlePath);
187
+ const raw = await readFile(resolved, "utf8");
188
+ return JSON.parse(raw) as SigstoreBundle;
189
+ }
190
+
191
+ async function verifyBundle(options: CLIOptions): Promise<void> {
192
+ const [trustedRoot, bundle, artifact] = await Promise.all([
193
+ loadTrustedRoot(options.trustedRootPath),
194
+ loadBundle(options.bundlePath),
195
+ resolveArtifact(options.artifactInput),
196
+ ]);
197
+
198
+ const verifier = new SigstoreVerifier();
199
+ await verifier.loadSigstoreRoot(trustedRoot);
200
+
201
+ // Extract and verify the artifact digest from the bundle
202
+ let bundleDigestHex: string;
203
+ let bundleDigestBytes: Uint8Array;
204
+
205
+ if (bundle.messageSignature) {
206
+ // Regular bundle: digest is in messageSignature
207
+ if (bundle.messageSignature.messageDigest.algorithm !== "SHA2_256") {
208
+ throw new Error(
209
+ `Unsupported message digest algorithm: ${bundle.messageSignature.messageDigest.algorithm}`,
210
+ );
211
+ }
212
+
213
+ bundleDigestBytes = base64ToUint8Array(
214
+ bundle.messageSignature.messageDigest.digest,
215
+ );
216
+ bundleDigestHex = Uint8ArrayToHex(bundleDigestBytes).toLowerCase();
217
+ } else if (bundle.dsseEnvelope) {
218
+ // DSSE bundle: digest is in the in-toto statement payload
219
+ const payloadBytes = base64ToUint8Array(bundle.dsseEnvelope.payload);
220
+ const payload = JSON.parse(Uint8ArrayToString(payloadBytes));
221
+
222
+ if (!payload.subject || payload.subject.length === 0) {
223
+ throw new Error("DSSE payload has no subject");
224
+ }
225
+
226
+ // Find matching subject by comparing artifact digest with all subjects
227
+ let matchedSubject = null;
228
+ for (const subject of payload.subject) {
229
+ const subjectDigest = subject.digest?.["sha256"];
230
+ if (subjectDigest && artifact.digestHex === subjectDigest.toLowerCase()) {
231
+ matchedSubject = subject;
232
+ break;
233
+ }
234
+ }
235
+
236
+ if (!matchedSubject) {
237
+ throw new Error(
238
+ `Artifact digest ${artifact.digestHex} does not match any subject in DSSE payload`
239
+ );
240
+ }
241
+
242
+ bundleDigestHex = matchedSubject.digest["sha256"].toLowerCase();
243
+
244
+ // Convert hex digest to bytes for verification target
245
+ bundleDigestBytes = hexToUint8Array(bundleDigestHex);
246
+ } else {
247
+ throw new Error("Bundle does not contain a message signature or DSSE envelope");
248
+ }
249
+
250
+ // For non-DSSE bundles, verify the digest matches
251
+ if (bundle.messageSignature && artifact.digestHex !== bundleDigestHex) {
252
+ throw new Error(
253
+ "Artifact digest does not match the digest embedded in the bundle.",
254
+ );
255
+ }
256
+
257
+ const verificationTarget =
258
+ artifact.type === "file" ? artifact.data : bundleDigestBytes;
259
+ const isDigestOnly = artifact.type === "digest";
260
+
261
+ await verifier.verifyArtifact(
262
+ options.certificateIdentity,
263
+ options.certificateOidcIssuer,
264
+ bundle,
265
+ verificationTarget,
266
+ isDigestOnly,
267
+ );
268
+ }
269
+
270
+ async function main(): Promise<void> {
271
+ try {
272
+ const options = parseArgs(process.argv.slice(2));
273
+ await verifyBundle(options);
274
+ console.log("Bundle verification succeeded.");
275
+ } catch (error) {
276
+ if (error instanceof Error) {
277
+ console.error(error.message);
278
+ } else {
279
+ console.error(error);
280
+ }
281
+ process.exitCode = 1;
282
+ }
283
+ }
284
+
285
+ await main();
package/src/crypto.ts ADDED
@@ -0,0 +1,310 @@
1
+ import { ASN1Obj } from "./asn1/index.js";
2
+ import {
3
+ base64ToUint8Array,
4
+ base64UrlToUint8Array,
5
+ hexToUint8Array,
6
+ toArrayBuffer,
7
+ Uint8ArrayToHex,
8
+ } from "./encoding.js";
9
+ import { EcdsaTypes, HashAlgorithms, KeyTypes } from "./interfaces.js";
10
+ import { toDER } from "./pem.js";
11
+ import { p256, p384, p521 } from "@noble/curves/nist.js";
12
+
13
+ function pkcs1ToSpki(pkcs1Bytes: Uint8Array): Uint8Array {
14
+ const algorithmIdentifier = new Uint8Array([
15
+ 0x30, 0x0d,
16
+ 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01,
17
+ 0x05, 0x00
18
+ ]);
19
+
20
+ const bitStringLength = pkcs1Bytes.length + 1;
21
+ const totalContentLength = algorithmIdentifier.length + 1 + lengthBytes(bitStringLength).length + bitStringLength;
22
+
23
+ const result = new Uint8Array(1 + lengthBytes(totalContentLength).length + totalContentLength);
24
+ let offset = 0;
25
+
26
+ result[offset++] = 0x30;
27
+ const totalLengthBytes = lengthBytes(totalContentLength);
28
+ result.set(totalLengthBytes, offset);
29
+ offset += totalLengthBytes.length;
30
+
31
+ result.set(algorithmIdentifier, offset);
32
+ offset += algorithmIdentifier.length;
33
+
34
+ result[offset++] = 0x03;
35
+ const bitStringLengthBytes = lengthBytes(bitStringLength);
36
+ result.set(bitStringLengthBytes, offset);
37
+ offset += bitStringLengthBytes.length;
38
+ result[offset++] = 0x00;
39
+ result.set(pkcs1Bytes, offset);
40
+
41
+ return result;
42
+ }
43
+
44
+ // Encodes a length value in DER (Distinguished Encoding Rules) format.
45
+ // DER length encoding rules:
46
+ // - Lengths 0-127: single byte containing the length
47
+ // - Lengths 128-255: 0x81 followed by one byte containing the length
48
+ // - Lengths 256-65535: 0x82 followed by two bytes containing the length (big-endian)
49
+ // Used by pkcs1ToSpki() to construct valid ASN.1/DER structures.
50
+ function lengthBytes(length: number): Uint8Array {
51
+ if (length < 128) {
52
+ return new Uint8Array([length]);
53
+ } else if (length < 256) {
54
+ return new Uint8Array([0x81, length]);
55
+ } else {
56
+ return new Uint8Array([0x82, (length >> 8) & 0xff, length & 0xff]);
57
+ }
58
+ }
59
+
60
+ // Imports cryptographic public keys into the Web Crypto API format (CryptoKey).
61
+ // This function is specific to the browser implementation and doesn't exist in sigstore-js.
62
+ //
63
+ // Why needed for browser:
64
+ // - sigstore-js uses Node.js crypto.createPublicKey() which handles many key formats automatically
65
+ // - Browsers only have Web Crypto API (crypto.subtle.importKey) which requires explicit format/algorithm
66
+ // - This function bridges the gap by:
67
+ // 1. Detecting key format (PEM, hex, base64, PKCS#1, SPKI)
68
+ // 2. Converting PKCS#1 RSA keys to SPKI (Web Crypto only supports SPKI for RSA)
69
+ // 3. Mapping Sigstore key types/schemes to Web Crypto algorithm parameters
70
+ //
71
+ // Supports:
72
+ // - ECDSA (P-256, P-384, P-521) - Used by Fulcio, CT logs
73
+ // - Ed25519 - Used by Rekor checkpoints, some CT logs
74
+ // - RSA (PKCS#1, PSS) - Used by older CT logs and some Rekor instances
75
+ //
76
+ // Key format detection:
77
+ // - PEM format (contains "BEGIN"): Parse with toDER()
78
+ // - Hex string (all hex chars): Import as raw
79
+ // - Base64 PKCS#1 (starts with 0x30 0x82...): Convert to SPKI
80
+ // - Base64 SPKI: Import directly
81
+ export async function importKey(
82
+ keytype: string,
83
+ scheme: string,
84
+ key: string,
85
+ ): Promise<CryptoKey> {
86
+ class importParams {
87
+ format: "raw" | "spki" = "spki";
88
+ keyData: ArrayBuffer = new ArrayBuffer(0);
89
+ algorithm: RsaHashedImportParams | EcKeyImportParams | Algorithm = { name: "ECDSA" };
90
+ extractable: boolean = true;
91
+ usage: Array<KeyUsage> = ["verify"];
92
+ }
93
+
94
+ const params = new importParams();
95
+ if (key.includes("BEGIN")) {
96
+ params.format = "spki";
97
+ params.keyData = toArrayBuffer(toDER(key));
98
+ } else if (/^[0-9A-Fa-f]+$/.test(key)) {
99
+ params.format = "raw";
100
+ params.keyData = toArrayBuffer(hexToUint8Array(key));
101
+ } else {
102
+ params.format = "spki";
103
+ const keyBytes = base64ToUint8Array(key);
104
+
105
+ if (keytype.toLowerCase().includes("pkcs1") &&
106
+ keyBytes[0] === 0x30 && keyBytes[1] === 0x82 &&
107
+ keyBytes[4] === 0x02 && keyBytes[5] === 0x82) {
108
+ params.keyData = toArrayBuffer(pkcs1ToSpki(keyBytes));
109
+ } else {
110
+ params.keyData = toArrayBuffer(keyBytes);
111
+ }
112
+ }
113
+
114
+ if (keytype.toLowerCase().includes("ecdsa")) {
115
+ if (scheme.includes("256")) {
116
+ params.algorithm = { name: "ECDSA", namedCurve: EcdsaTypes.P256 };
117
+ } else if (scheme.includes("384")) {
118
+ params.algorithm = { name: "ECDSA", namedCurve: EcdsaTypes.P384 };
119
+ } else if (scheme.includes("521")) {
120
+ params.algorithm = { name: "ECDSA", namedCurve: EcdsaTypes.P521 };
121
+ } else {
122
+ throw new Error("Cannot determine ECDSA key size.");
123
+ }
124
+ } else if (keytype.toLowerCase().includes("ed25519")) {
125
+ params.algorithm = { name: "Ed25519" };
126
+ } else if (keytype.toLowerCase().includes("rsa") || keytype.toLowerCase().includes("pkcs1")) {
127
+ let hashName = HashAlgorithms.SHA256;
128
+ // Normalize scheme to handle various formats: SHA256, SHA_256, SHA-256, etc.
129
+ const normalizedScheme = scheme.toUpperCase().replace(/[-_]/g, "");
130
+ if (normalizedScheme.includes("SHA256") || normalizedScheme.includes("256")) {
131
+ hashName = HashAlgorithms.SHA256;
132
+ } else if (normalizedScheme.includes("SHA384") || normalizedScheme.includes("384")) {
133
+ hashName = HashAlgorithms.SHA384;
134
+ } else if (normalizedScheme.includes("SHA512") || normalizedScheme.includes("512")) {
135
+ hashName = HashAlgorithms.SHA512;
136
+ }
137
+
138
+ if (scheme.includes("PKCS1") || scheme.includes("RSA_PKCS1")) {
139
+ params.algorithm = {
140
+ name: "RSASSA-PKCS1-v1_5",
141
+ hash: { name: hashName },
142
+ };
143
+ } else {
144
+ params.algorithm = {
145
+ name: "RSA-PSS",
146
+ hash: { name: hashName },
147
+ };
148
+ }
149
+ } else {
150
+ throw new Error(`Unsupported ${keytype}`);
151
+ }
152
+
153
+ return await crypto.subtle.importKey(
154
+ params.format,
155
+ params.keyData,
156
+ params.algorithm,
157
+ params.extractable,
158
+ params.usage,
159
+ );
160
+ }
161
+
162
+ export async function verifySignature(
163
+ key: CryptoKey,
164
+ signed: Uint8Array,
165
+ sig: Uint8Array,
166
+ hash: string = "sha256",
167
+ ): Promise<boolean> {
168
+ const options: {
169
+ name: string;
170
+ hash?: {
171
+ name: string;
172
+ };
173
+ } = {
174
+ name: key.algorithm.name,
175
+ };
176
+
177
+ if (key.algorithm.name === KeyTypes.Ecdsa) {
178
+ const namedCurve = (key.algorithm as EcKeyAlgorithm).namedCurve;
179
+ let sig_size = 32;
180
+
181
+ if (namedCurve === EcdsaTypes.P256) {
182
+ sig_size = 32;
183
+ } else if (namedCurve === EcdsaTypes.P384) {
184
+ sig_size = 48;
185
+ } else if (namedCurve === EcdsaTypes.P521) {
186
+ sig_size = 66;
187
+ }
188
+
189
+ options.hash = { name: "" };
190
+ if (hash.includes("256")) {
191
+ options.hash.name = HashAlgorithms.SHA256;
192
+ } else if (hash.includes("384")) {
193
+ options.hash.name = HashAlgorithms.SHA384;
194
+ } else if (hash.includes("512")) {
195
+ options.hash.name = HashAlgorithms.SHA512;
196
+ } else {
197
+ throw new Error("Cannot determine hashing algorithm;");
198
+ }
199
+
200
+ let raw_signature: Uint8Array;
201
+ try {
202
+ const asn1_sig = ASN1Obj.parseBuffer(sig);
203
+ const r = asn1_sig.subs[0].toInteger();
204
+ const s = asn1_sig.subs[1].toInteger();
205
+ const binr = hexToUint8Array(r.toString(16).padStart(sig_size * 2, "0"));
206
+ const bins = hexToUint8Array(s.toString(16).padStart(sig_size * 2, "0"));
207
+ raw_signature = new Uint8Array(binr.length + bins.length);
208
+ raw_signature.set(binr, 0);
209
+ raw_signature.set(bins, binr.length);
210
+ } catch {
211
+ return false;
212
+ }
213
+
214
+ return await crypto.subtle.verify(
215
+ options,
216
+ key,
217
+ toArrayBuffer(raw_signature),
218
+ toArrayBuffer(signed),
219
+ );
220
+ } else if (key.algorithm.name === KeyTypes.Ed25519) {
221
+ // Ed25519 uses raw signatures (not DER-encoded like ECDSA)
222
+ // Fulcio supports Ed25519, ECDSA, and RSA_PSS for signing certificates
223
+ // Ed25519 is also used for checkpoint signatures in TLog configurations
224
+ return await crypto.subtle.verify(
225
+ key.algorithm.name,
226
+ key,
227
+ toArrayBuffer(sig),
228
+ toArrayBuffer(signed),
229
+ );
230
+ } else if (key.algorithm.name === "RSA-PSS") {
231
+ // Salt length must match the hash output size
232
+ const hashAlg = (key.algorithm as RsaHashedKeyAlgorithm).hash.name;
233
+ const saltLength = hashAlg === HashAlgorithms.SHA256 ? 32 :
234
+ hashAlg === HashAlgorithms.SHA384 ? 48 :
235
+ hashAlg === HashAlgorithms.SHA512 ? 64 : 32;
236
+ return await crypto.subtle.verify(
237
+ {
238
+ name: "RSA-PSS",
239
+ saltLength: saltLength,
240
+ },
241
+ key,
242
+ toArrayBuffer(sig),
243
+ toArrayBuffer(signed),
244
+ );
245
+ } else if (key.algorithm.name === "RSASSA-PKCS1-v1_5") {
246
+ return await crypto.subtle.verify(
247
+ key.algorithm.name,
248
+ key,
249
+ toArrayBuffer(sig),
250
+ toArrayBuffer(signed),
251
+ );
252
+ } else {
253
+ throw new Error("Unsupported key type!");
254
+ }
255
+ }
256
+
257
+ // Verifies an ECDSA signature over a pre-computed digest.
258
+ // WebCrypto's verify() always hashes the input first, so passing a digest would
259
+ // result in double-hashing. We use @noble/curves for low-level ECDSA verification,
260
+ // adapted from the same workaround used in sigstore-js's conformance CLI.
261
+ // See: https://github.com/sigstore/sigstore-js/blob/main/packages/conformance/src/commands/verify-bundle.ts#L111
262
+ export async function verifySignatureOverDigest(
263
+ key: CryptoKey,
264
+ digest: Uint8Array,
265
+ sig: Uint8Array,
266
+ ): Promise<boolean> {
267
+ if (key.algorithm.name !== KeyTypes.Ecdsa) {
268
+ throw new Error("verifySignatureOverDigest only supports ECDSA keys");
269
+ }
270
+
271
+ const namedCurve = (key.algorithm as EcKeyAlgorithm).namedCurve;
272
+ let curve: typeof p256 | typeof p384 | typeof p521;
273
+
274
+ if (namedCurve === EcdsaTypes.P256) {
275
+ curve = p256;
276
+ } else if (namedCurve === EcdsaTypes.P384) {
277
+ curve = p384;
278
+ } else if (namedCurve === EcdsaTypes.P521) {
279
+ curve = p521;
280
+ } else {
281
+ throw new Error(`Unsupported curve: ${namedCurve}`);
282
+ }
283
+
284
+ // Export the public key to get x and y coordinates
285
+ const jwk = await crypto.subtle.exportKey("jwk", key);
286
+ if (!jwk.x || !jwk.y) {
287
+ throw new Error("Invalid ECDSA public key: missing x or y coordinates");
288
+ }
289
+
290
+ // Convert base64url JWK coordinates to bytes
291
+ const x = base64UrlToUint8Array(jwk.x);
292
+ const y = base64UrlToUint8Array(jwk.y);
293
+
294
+ // Combine x and y into uncompressed public key format (0x04 || x || y)
295
+ const publicKey = new Uint8Array(1 + x.length + y.length);
296
+ publicKey[0] = 0x04;
297
+ publicKey.set(x, 1);
298
+ publicKey.set(y, 1 + x.length);
299
+
300
+ // Verify the DER-encoded signature over the digest
301
+ // @noble/curves can parse DER signatures directly with format: 'der'
302
+ // Options:
303
+ // - format: 'der' to parse DER-encoded signatures
304
+ // - prehash: false because we're passing a pre-computed digest, not the original message
305
+ // - lowS: false to accept both high-S and low-S signatures (matches elliptic.js behavior)
306
+ // elliptic.js accepts malleable signatures by default, while @noble/curves rejects them by default
307
+ return curve.verify(sig, digest, publicKey, { format: 'der', prehash: false, lowS: false });
308
+ }
309
+
310
+ export { uint8ArrayEqual as bufferEqual } from "./encoding.js";
package/src/dsse.ts ADDED
@@ -0,0 +1,49 @@
1
+ /*
2
+ Copyright 2023 The Sigstore Authors.
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+ const PAE_PREFIX = "DSSEv1";
17
+
18
+ // DSSE Pre-Authentication Encoding
19
+ export function preAuthEncoding(
20
+ payloadType: string,
21
+ payload: Uint8Array,
22
+ ): Uint8Array {
23
+ const prefix = [
24
+ PAE_PREFIX,
25
+ payloadType.length,
26
+ payloadType,
27
+ payload.length,
28
+ "",
29
+ ].join(" ");
30
+
31
+ // DSSE spec requires ASCII encoding for the prefix
32
+ // TextEncoder will throw if the string contains non-ASCII characters
33
+ const encoder = new TextEncoder();
34
+ const prefixBuffer = encoder.encode(prefix);
35
+
36
+ // Verify all characters are ASCII (< 128)
37
+ for (let i = 0; i < prefixBuffer.length; i++) {
38
+ if (prefixBuffer[i] > 127) {
39
+ throw new Error(`Invalid non-ASCII character in PAE prefix at position ${i}`);
40
+ }
41
+ }
42
+
43
+ // Concatenate prefix and payload
44
+ const combinedArray = new Uint8Array(prefixBuffer.length + payload.length);
45
+
46
+ combinedArray.set(prefixBuffer, 0);
47
+ combinedArray.set(payload, prefixBuffer.length);
48
+ return combinedArray;
49
+ }