vana-cli 0.24.5 → 0.26.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 (103) hide show
  1. package/README.md +1 -0
  2. package/dist/cli/auth.d.ts +2 -0
  3. package/dist/cli/auth.d.ts.map +1 -1
  4. package/dist/cli/auth.js +2 -1
  5. package/dist/cli/auth.js.map +1 -1
  6. package/dist/cli/index.d.ts +2 -0
  7. package/dist/cli/index.d.ts.map +1 -1
  8. package/dist/cli/index.js +128 -13
  9. package/dist/cli/index.js.map +1 -1
  10. package/dist/cli/server-start.d.ts +42 -0
  11. package/dist/cli/server-start.d.ts.map +1 -0
  12. package/dist/cli/server-start.js +189 -0
  13. package/dist/cli/server-start.js.map +1 -0
  14. package/dist/connectors/registry.d.ts +2 -0
  15. package/dist/connectors/registry.d.ts.map +1 -1
  16. package/dist/connectors/registry.js +18 -2
  17. package/dist/connectors/registry.js.map +1 -1
  18. package/dist/core/cli-types.d.ts +44 -0
  19. package/dist/core/cli-types.d.ts.map +1 -1
  20. package/dist/core/cli-types.js +14 -0
  21. package/dist/core/cli-types.js.map +1 -1
  22. package/dist/core/state-store.d.ts +6 -0
  23. package/dist/core/state-store.d.ts.map +1 -1
  24. package/dist/core/state-store.js.map +1 -1
  25. package/dist/pdpp/host.d.ts +66 -0
  26. package/dist/pdpp/host.d.ts.map +1 -0
  27. package/dist/pdpp/host.js +283 -0
  28. package/dist/pdpp/host.js.map +1 -0
  29. package/dist/pdpp/installer.d.ts +17 -0
  30. package/dist/pdpp/installer.d.ts.map +1 -0
  31. package/dist/pdpp/installer.js +117 -0
  32. package/dist/pdpp/installer.js.map +1 -0
  33. package/dist/pdpp/local.d.ts +14 -0
  34. package/dist/pdpp/local.d.ts.map +1 -0
  35. package/dist/pdpp/local.js +76 -0
  36. package/dist/pdpp/local.js.map +1 -0
  37. package/dist/pdpp/pins.d.ts +23 -0
  38. package/dist/pdpp/pins.d.ts.map +1 -0
  39. package/dist/pdpp/pins.js +18 -0
  40. package/dist/pdpp/pins.js.map +1 -0
  41. package/dist/pdpp/profile.d.ts +49 -0
  42. package/dist/pdpp/profile.d.ts.map +1 -0
  43. package/dist/pdpp/profile.js +17 -0
  44. package/dist/pdpp/profile.js.map +1 -0
  45. package/dist/pdpp/protocol.d.ts +79 -0
  46. package/dist/pdpp/protocol.d.ts.map +1 -0
  47. package/dist/pdpp/protocol.js +372 -0
  48. package/dist/pdpp/protocol.js.map +1 -0
  49. package/dist/pdpp/runtime.d.ts +43 -0
  50. package/dist/pdpp/runtime.d.ts.map +1 -0
  51. package/dist/pdpp/runtime.js +387 -0
  52. package/dist/pdpp/runtime.js.map +1 -0
  53. package/dist/pdpp/store.d.ts +43 -0
  54. package/dist/pdpp/store.d.ts.map +1 -0
  55. package/dist/pdpp/store.js +100 -0
  56. package/dist/pdpp/store.js.map +1 -0
  57. package/dist/personal-server/index.d.ts +1 -0
  58. package/dist/personal-server/index.d.ts.map +1 -1
  59. package/dist/personal-server/index.js +17 -2
  60. package/dist/personal-server/index.js.map +1 -1
  61. package/dist/personal-server/local/config.d.ts +23 -0
  62. package/dist/personal-server/local/config.d.ts.map +1 -0
  63. package/dist/personal-server/local/config.js +51 -0
  64. package/dist/personal-server/local/config.js.map +1 -0
  65. package/dist/personal-server/local/owner-binding.d.ts +33 -0
  66. package/dist/personal-server/local/owner-binding.d.ts.map +1 -0
  67. package/dist/personal-server/local/owner-binding.js +155 -0
  68. package/dist/personal-server/local/owner-binding.js.map +1 -0
  69. package/dist/personal-server/local/owner-secret.d.ts +17 -0
  70. package/dist/personal-server/local/owner-secret.d.ts.map +1 -0
  71. package/dist/personal-server/local/owner-secret.js +119 -0
  72. package/dist/personal-server/local/owner-secret.js.map +1 -0
  73. package/dist/personal-server/local/runtime-pkg/entry.mjs +153 -0
  74. package/dist/personal-server/local/runtime-pkg/package-lock.json +2342 -0
  75. package/dist/personal-server/local/runtime-pkg/package.json +13 -0
  76. package/dist/personal-server/local/runtime.d.ts +9 -0
  77. package/dist/personal-server/local/runtime.d.ts.map +1 -0
  78. package/dist/personal-server/local/runtime.js +66 -0
  79. package/dist/personal-server/local/runtime.js.map +1 -0
  80. package/dist/personal-server/local/server.d.ts +39 -0
  81. package/dist/personal-server/local/server.d.ts.map +1 -0
  82. package/dist/personal-server/local/server.js +203 -0
  83. package/dist/personal-server/local/server.js.map +1 -0
  84. package/dist/runtime/core/contracts.d.ts +2 -0
  85. package/dist/runtime/core/contracts.d.ts.map +1 -1
  86. package/dist/runtime/managed-playwright.d.ts +7 -0
  87. package/dist/runtime/managed-playwright.d.ts.map +1 -1
  88. package/dist/runtime/managed-playwright.js +7 -0
  89. package/dist/runtime/managed-playwright.js.map +1 -1
  90. package/dist/vendor/pdpp-connector-manager/LICENSE +202 -0
  91. package/dist/vendor/pdpp-connector-manager/NOTICE +2 -0
  92. package/dist/vendor/pdpp-connector-manager/VENDOR.md +27 -0
  93. package/dist/vendor/pdpp-connector-manager/catalog-schema-data.mjs +168 -0
  94. package/dist/vendor/pdpp-connector-manager/catalog-schema.mjs +62 -0
  95. package/dist/vendor/pdpp-connector-manager/index.d.mts +49 -0
  96. package/dist/vendor/pdpp-connector-manager/index.mjs +1646 -0
  97. package/dist/vendor/pdpp-connector-manager/oci-catalog.mjs +93 -0
  98. package/dist/vendor/pdpp-connector-manager/oci-registry.mjs +1002 -0
  99. package/dist/vendor/pdpp-connector-manager/oci-verify.mjs +515 -0
  100. package/dist/vendor/pdpp-connector-manager/package.json +51 -0
  101. package/dist/vendor/pdpp-connector-manager/retry.mjs +142 -0
  102. package/dist/vendor/pdpp-connector-manager/tar-stream.mjs +685 -0
  103. package/package.json +7 -2
@@ -0,0 +1,515 @@
1
+ // Copyright The PDP-Connect Contributors
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ // The trust half of OCI connector consumption: prove that the bytes the
5
+ // registry served are the bytes this repository's publish workflow signed, and
6
+ // refuse everything else.
7
+ //
8
+ // WHY THIS IS NOT `sigstore.verify(bundle, payload)` AND DONE. The tarball path
9
+ // verifies a Sigstore bundle fetched from a sibling URL — one file, already in
10
+ // the shape the library reads. Cosign does not publish a bundle. It publishes a
11
+ // SEPARATE REGISTRY OBJECT at a tag derived from the digest, and scatters the
12
+ // pieces of a bundle across that object's manifest:
13
+ //
14
+ // tag `sha256-<hex>.sig` (the digest, `:`→`-`)
15
+ // layer application/vnd.dev.cosign.simplesigning.v1+json
16
+ // body the simple-signing payload — the signed bytes
17
+ // ann. dev.cosignproject.cosign/signature base64 signature over the body
18
+ // ann. dev.sigstore.cosign/certificate PEM Fulcio cert (keyless)
19
+ //
20
+ // So the work here is to fetch those pieces and reassemble the bundle the
21
+ // library can verify. The layout above is not read off the cosign docs: it was
22
+ // observed against cosign v2.4.3 — the version the publish workflow pins —
23
+ // pushing to a local registry, and the signature was confirmed to be a plain
24
+ // ECDSA-SHA256 signature over the RAW payload bytes, which is exactly what
25
+ // `toMessageSignatureBundle` expects. `oci.test.mjs` builds its fixtures the
26
+ // same way, so the assembly below is exercised against the real shape rather
27
+ // than against an idea of it.
28
+ //
29
+ // THE TWO DIGESTS, AND WHY BOTH ARE CHECKED. The payload cosign signs does not
30
+ // contain the artifact; it contains a CLAIM about the artifact, in
31
+ // `critical.image.docker-manifest-digest`. A valid signature over a payload
32
+ // naming a DIFFERENT manifest is a real signature by the real signer that says
33
+ // nothing about the bytes being installed — it is a signature lifted from
34
+ // another artifact. So the signature must verify AND the digest it names must
35
+ // be the digest being installed (C3.3). Verifying only the first is the mistake
36
+ // this file exists to not make.
37
+ //
38
+ // TLOG, AND WHY IT IS REQUIRED RATHER THAN SKIPPED. `toMessageSignatureBundle`
39
+ // produces a bundle with no transparency-log entries, because cosign keeps the
40
+ // Rekor entry in its own annotation — `dev.sigstore.cosign/bundle` — rather
41
+ // than in the layer. Reading that annotation is therefore work, and skipping it
42
+ // costs something specific: a Fulcio certificate is valid for ten minutes, and
43
+ // with no tlog entry and no RFC3161 timestamp `@sigstore/verify` has NO
44
+ // TIMESTAMP to check that validity window against. Certificate expiry stops
45
+ // being checked at all, so a signature made inside the window is
46
+ // indistinguishable from one made a year later with a leaked ephemeral key.
47
+ //
48
+ // The publish workflow runs `cosign sign --yes` with no `--tlog-upload=false`,
49
+ // and cosign 2.4.3 uploads to Rekor by default, so the annotation is present on
50
+ // everything this repository publishes. It carries the SET, the canonicalized
51
+ // body, `integratedTime` and `logIndex`, which is exactly a bundle
52
+ // `tlogEntries[]` with an `inclusionPromise` — verified OFFLINE against the
53
+ // Rekor public key in the trusted root, with no network call. So the entry is
54
+ // parsed, `tlogThreshold` stays at the library default of 1, and a signature
55
+ // object without one is refused rather than accepted on a weaker basis.
56
+ //
57
+ // `ctLogThreshold` stays at the default too: real Fulcio leaves embed an SCT.
58
+
59
+ import {
60
+ BUNDLE_V01_MEDIA_TYPE,
61
+ bundleToJSON,
62
+ toMessageSignatureBundle,
63
+ } from "@sigstore/bundle";
64
+ import { verify as verifySigstoreBundle } from "sigstore";
65
+
66
+ import {
67
+ OciRegistryError,
68
+ fetchBlob,
69
+ fetchSignatureManifest,
70
+ isValidDigest,
71
+ sha256Digest,
72
+ } from "./oci-registry.mjs";
73
+
74
+ // THE PIN IS A REGULAR EXPRESSION, SO IT MUST BE ANCHORED. sigstore matches
75
+ // `certificateIdentityURI` with `signerIdentity.match(policyIdentity)` — an
76
+ // UNANCHORED regular expression against the certificate SAN
77
+ // (`@sigstore/verify/dist/policy.js`; the sigstore README says "for exact
78
+ // matching, use an anchored pattern"). The pinned identity ends in
79
+ // `@refs/heads/main`, so passing it verbatim also accepts
80
+ // `@refs/heads/mainline`, `@refs/heads/main2`, `@refs/heads/main-fix` — any
81
+ // branch of this repository whose name extends `main` — and any SAN that
82
+ // merely contains the pinned string, including one from another repository.
83
+ // That is exactly the case the pin exists for: a collaborator with branch push
84
+ // rights edits the `github.ref == 'refs/heads/main'` gate out of the workflow
85
+ // on such a branch, runs it, and Fulcio mints a certificate a verbatim pin
86
+ // accepts. The constant stays human-readable; the pattern is derived here.
87
+ export function toAnchoredIdentityPattern(identity) {
88
+ return `^${identity.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`;
89
+ }
90
+
91
+ export const COSIGN_SIGNATURE_MEDIA_TYPE =
92
+ "application/vnd.dev.cosign.simplesigning.v1+json";
93
+ export const COSIGN_SIGNATURE_ANNOTATION = "dev.cosignproject.cosign/signature";
94
+ export const COSIGN_CERTIFICATE_ANNOTATION = "dev.sigstore.cosign/certificate";
95
+ export const COSIGN_BUNDLE_ANNOTATION = "dev.sigstore.cosign/bundle";
96
+
97
+ // The layer media types the connector artifact contract defines. Selection is
98
+ // BY MEDIA TYPE, never by position: `assets.tar.gz` is emitted only when the
99
+ // profile declares a brand icon, so every later layer shifts when it is absent
100
+ // (C4.2).
101
+ export const OCI_LAYER_MEDIA_TYPES = {
102
+ profile: "application/vnd.pdpp.connector.profile.v1+json",
103
+ code: "application/vnd.pdpp.connector.code.v1.tar+gzip",
104
+ assets: "application/vnd.pdpp.connector.assets.v1.tar+gzip",
105
+ licenses: "application/vnd.pdpp.connector.licenses.v1.tar+gzip",
106
+ provenance: "application/vnd.pdpp.connector.provenance.v1+json",
107
+ };
108
+
109
+ export const OCI_CONFIG_MEDIA_TYPE =
110
+ "application/vnd.pdpp.connector.config.v1+json";
111
+
112
+ const KNOWN_LAYER_MEDIA_TYPES = new Set(Object.values(OCI_LAYER_MEDIA_TYPES));
113
+
114
+ // Which layers an artifact cannot be without. `assets` is deliberately absent
115
+ // from this list, and that absence is the thing A-T6 pins.
116
+ const REQUIRED_LAYERS = ["profile", "code", "licenses", "provenance"];
117
+
118
+ export const DEFAULT_OCI_SIGSTORE_CERTIFICATE_ISSUER =
119
+ "https://token.actions.githubusercontent.com";
120
+
121
+ // The identity the publish workflow signs as. EXACT match, never a prefix: the
122
+ // trailing `@refs/heads/main` is what distinguishes a signature minted by a run
123
+ // on main from one minted by a run on any branch, and prefix-matching it would
124
+ // accept `...publish-polyfill-connectors.yml@refs/heads/attacker` (C3.1, C3.2).
125
+ export const DEFAULT_OCI_SIGSTORE_CERTIFICATE_IDENTITY =
126
+ "https://github.com/PDP-Connect/data-connectors/.github/workflows/publish-polyfill-connectors.yml@refs/heads/main";
127
+
128
+ /**
129
+ * Reference → trusted identity, fail-closed.
130
+ *
131
+ * The OCI analogue of the tarball path's URL→identity resolver. It returns a
132
+ * string for a reference this consumer trusts and `null` for everything else,
133
+ * and `null` is a hard refusal at the call site — never a downgrade to
134
+ * unsigned, and never a fallback to a default (C3.2).
135
+ *
136
+ * Note what it is a function OF: the registry and repository — the coordinates
137
+ * the LOCK named. It is not passed the artifact, and that is the point (C3.5):
138
+ * if the thing being verified could select the identity it is verified against,
139
+ * verification would prove only that the artifact agrees with itself.
140
+ */
141
+ export function defaultOciCertificateIdentityResolver({ registry, repository }) {
142
+ if (registry !== "ghcr.io") return null;
143
+ if (!/^pdp-connect\/connector\/[a-z0-9][a-z0-9-]*$/.test(repository)) return null;
144
+ return DEFAULT_OCI_SIGSTORE_CERTIFICATE_IDENTITY;
145
+ }
146
+
147
+ /**
148
+ * Pull the signature, certificate and signed payload out of a cosign `.sig`
149
+ * manifest.
150
+ *
151
+ * A `.sig` manifest may carry MORE THAN ONE signature layer — cosign appends
152
+ * rather than replaces, so a re-signed artifact accumulates them. Every layer
153
+ * is returned and the caller tries each, because a single valid signature by
154
+ * the pinned identity is what authorises the install; an older signature by a
155
+ * rotated key sitting alongside it is not a reason to refuse.
156
+ */
157
+ export function extractCosignSignatures(signatureManifest) {
158
+ const layers = Array.isArray(signatureManifest?.layers) ? signatureManifest.layers : [];
159
+ const signatures = [];
160
+
161
+ for (const layer of layers) {
162
+ if (layer?.mediaType !== COSIGN_SIGNATURE_MEDIA_TYPE) continue;
163
+ const signature = layer?.annotations?.[COSIGN_SIGNATURE_ANNOTATION];
164
+ const certificate = layer?.annotations?.[COSIGN_CERTIFICATE_ANNOTATION];
165
+ if (typeof signature !== "string" || signature.length === 0) continue;
166
+ if (!isValidDigest(layer?.digest)) continue;
167
+ const bundle = layer?.annotations?.[COSIGN_BUNDLE_ANNOTATION];
168
+ signatures.push({
169
+ signature,
170
+ certificate: typeof certificate === "string" ? certificate : null,
171
+ rekorBundle: typeof bundle === "string" ? bundle : null,
172
+ payloadDigest: layer.digest,
173
+ });
174
+ }
175
+
176
+ return signatures;
177
+ }
178
+
179
+ /**
180
+ * Turn cosign's `dev.sigstore.cosign/bundle` annotation into a bundle tlog entry.
181
+ *
182
+ * The annotation is cosign's own JSON: `{ SignedEntryTimestamp, Payload: {
183
+ * body, integratedTime, logIndex, logID } }`. Everything needed to verify the
184
+ * inclusion promise offline is in there — the SET is over the canonicalized
185
+ * `{body, integratedTime, logIndex, logID}`, checked against the Rekor public
186
+ * key in the trusted root.
187
+ *
188
+ * `kindVersion` is read from the body rather than assumed, because the body is
189
+ * what the SET covers and what `verifyTLogBody` re-checks against the
190
+ * signature; asserting a kind here that the body contradicts would be a claim
191
+ * this function is not entitled to make. Anything malformed returns null and
192
+ * the caller refuses — an unparseable inclusion promise is not a verified one.
193
+ */
194
+ export function parseCosignRekorBundle(annotation) {
195
+ let parsed;
196
+ try {
197
+ parsed = JSON.parse(annotation);
198
+ } catch {
199
+ return null;
200
+ }
201
+
202
+ const set = parsed?.SignedEntryTimestamp;
203
+ const payload = parsed?.Payload;
204
+ const body = payload?.body;
205
+ const logId = payload?.logID;
206
+ if (typeof set !== "string" || set.length === 0) return null;
207
+ if (typeof body !== "string" || body.length === 0) return null;
208
+ if (typeof logId !== "string" || !/^[0-9a-f]{64}$/i.test(logId)) return null;
209
+ if (!Number.isInteger(payload?.integratedTime)) return null;
210
+ if (!Number.isInteger(payload?.logIndex)) return null;
211
+
212
+ let decodedBody;
213
+ try {
214
+ decodedBody = JSON.parse(Buffer.from(body, "base64").toString("utf8"));
215
+ } catch {
216
+ return null;
217
+ }
218
+ const kind = decodedBody?.kind;
219
+ const version = decodedBody?.apiVersion;
220
+ if (typeof kind !== "string" || typeof version !== "string") return null;
221
+
222
+ return {
223
+ logIndex: String(payload.logIndex),
224
+ logId: { keyId: Buffer.from(logId, "hex").toString("base64") },
225
+ kindVersion: { kind, version },
226
+ integratedTime: String(payload.integratedTime),
227
+ inclusionPromise: { signedEntryTimestamp: set },
228
+ canonicalizedBody: body,
229
+ };
230
+ }
231
+
232
+ /**
233
+ * Check that a simple-signing payload is about the artifact being installed.
234
+ *
235
+ * `critical.image.docker-manifest-digest` is the claim; the manifest digest the
236
+ * lock pinned is the fact. They must be equal, or the signature — however
237
+ * valid — belongs to a different artifact (C3.3).
238
+ */
239
+ export function assertPayloadNamesDigest(payloadBytes, manifestDigest) {
240
+ let payload;
241
+ try {
242
+ payload = JSON.parse(payloadBytes.toString("utf8"));
243
+ } catch (error) {
244
+ throw new OciRegistryError(
245
+ `The cosign signature payload is not JSON: ${error.message}`,
246
+ "tampered"
247
+ );
248
+ }
249
+
250
+ const claimed = payload?.critical?.image?.["docker-manifest-digest"];
251
+ if (typeof claimed !== "string" || claimed.length === 0) {
252
+ throw new OciRegistryError(
253
+ "The cosign signature payload names no manifest digest",
254
+ "tampered"
255
+ );
256
+ }
257
+ if (claimed.trim() !== manifestDigest.trim()) {
258
+ throw new OciRegistryError(
259
+ `The cosign signature covers manifest ${claimed.trim()}, not ${manifestDigest.trim()}`,
260
+ "misidentified"
261
+ );
262
+ }
263
+ return payload;
264
+ }
265
+
266
+ /**
267
+ * Assemble a cosign signature into the bundle shape `sigstore` verifies.
268
+ *
269
+ * The signature is over the raw payload bytes, so it becomes a message
270
+ * signature whose digest is those bytes' SHA-256, with the Fulcio certificate
271
+ * as the verification material.
272
+ */
273
+ function assembleBundle({ payloadBytes, signature, certificate, tlogEntry }) {
274
+ const der = Buffer.from(
275
+ certificate
276
+ .replace(/-----BEGIN CERTIFICATE-----/g, "")
277
+ .replace(/-----END CERTIFICATE-----/g, "")
278
+ .replace(/\s+/g, ""),
279
+ "base64"
280
+ );
281
+
282
+ const bundle = bundleToJSON(
283
+ toMessageSignatureBundle({
284
+ digest: Buffer.from(sha256Digest(payloadBytes).slice("sha256:".length), "hex"),
285
+ signature: Buffer.from(signature, "base64"),
286
+ certificate: der,
287
+ })
288
+ );
289
+
290
+ // The library builder has no parameter for a tlog entry, so the parsed entry
291
+ // is attached to the serialized bundle it produced. Nothing here is trusted:
292
+ // the entry is re-parsed by `bundleFromJSON` and its inclusion promise is
293
+ // checked against the trusted root's Rekor key inside sigstore.
294
+ bundle.verificationMaterial.tlogEntries = [tlogEntry];
295
+
296
+ // AND THE BUNDLE IS DECLARED v0.1, WHICH IS NOT A DOWNGRADE. Cosign's
297
+ // annotation carries an inclusion PROMISE (the SET) and no inclusion PROOF —
298
+ // no checkpoint, no Merkle path. Those are different bundle versions in the
299
+ // spec, and `bundleFromJSON` enforces the difference: v0.2 and later REQUIRE
300
+ // an inclusion proof and reject a promise-only entry as an invalid bundle,
301
+ // while v0.1 requires the promise. `toMessageSignatureBundle` emits v0.3, so
302
+ // leaving its media type would make every cosign signature unparseable.
303
+ // v0.1 is the version that describes what cosign actually published, and the
304
+ // promise is verified either way — `verifyTLogInclusion` checks the SET
305
+ // against the trusted root's Rekor key offline.
306
+ bundle.mediaType = BUNDLE_V01_MEDIA_TYPE;
307
+ return bundle;
308
+ }
309
+
310
+ /**
311
+ * Verify that a manifest digest was signed by the pinned identity.
312
+ *
313
+ * Every refusal path here is a refusal, never a downgrade: a missing signature,
314
+ * a signature that verifies under another identity, and a signature naming
315
+ * another artifact all abort the install (C3.1, C3.3, C3.4). Returns the
316
+ * identity that was proven, so a caller can log what it trusted rather than
317
+ * what it hoped for.
318
+ */
319
+ export async function verifyOciSignature({
320
+ registry,
321
+ repository,
322
+ digest,
323
+ scheme = "https",
324
+ timeoutMs = 30000,
325
+ fetchImpl = fetch,
326
+ retryOptions = {},
327
+ certificateIdentityResolver = defaultOciCertificateIdentityResolver,
328
+ certificateIssuer = DEFAULT_OCI_SIGSTORE_CERTIFICATE_ISSUER,
329
+ sigstoreVerifier = verifySigstoreBundle,
330
+ }) {
331
+ // Resolved from the LOCK'S coordinates, before the artifact is consulted, so
332
+ // nothing the artifact carries can influence what it is checked against.
333
+ const certificateIdentityURI = await certificateIdentityResolver({ registry, repository });
334
+ if (typeof certificateIdentityURI !== "string" || certificateIdentityURI.length === 0) {
335
+ throw new OciRegistryError(
336
+ `No trusted Sigstore certificate identity configured for ${registry}/${repository}`,
337
+ "misidentified"
338
+ );
339
+ }
340
+
341
+ const signatureObject = await fetchSignatureManifest({
342
+ registry,
343
+ repository,
344
+ digest,
345
+ scheme,
346
+ timeoutMs,
347
+ fetchImpl,
348
+ retryOptions,
349
+ });
350
+ if (signatureObject === null) {
351
+ throw new OciRegistryError(
352
+ `${registry}/${repository}@${digest} has no cosign signature`,
353
+ "unsigned"
354
+ );
355
+ }
356
+
357
+ const signatures = extractCosignSignatures(signatureObject.manifest);
358
+ if (signatures.length === 0) {
359
+ throw new OciRegistryError(
360
+ `The cosign signature object for ${registry}/${repository}@${digest} carries no usable signature layer`,
361
+ "unsigned"
362
+ );
363
+ }
364
+
365
+ const failures = [];
366
+ for (const candidate of signatures) {
367
+ if (!candidate.certificate) {
368
+ failures.push("a signature layer carries no Fulcio certificate");
369
+ continue;
370
+ }
371
+
372
+ // Without an inclusion promise there is no timestamp, and without a
373
+ // timestamp the certificate's ten-minute validity window is never checked.
374
+ // Refuse rather than fall back to the weaker basis (D2).
375
+ const tlogEntry = candidate.rekorBundle
376
+ ? parseCosignRekorBundle(candidate.rekorBundle)
377
+ : null;
378
+ if (!tlogEntry) {
379
+ failures.push(
380
+ candidate.rekorBundle
381
+ ? "a signature layer carries an unreadable Rekor inclusion promise"
382
+ : "a signature layer carries no Rekor inclusion promise"
383
+ );
384
+ continue;
385
+ }
386
+
387
+ // The WHOLE evaluation of this candidate is fallible, not just the
388
+ // cryptographic call. Fetching the payload and checking which digest it
389
+ // names can both throw, and with only the verifier guarded those throws
390
+ // left the loop — so one candidate whose payload blob is missing or names
391
+ // another artifact aborted the search before a later, valid signature was
392
+ // ever examined. An object can carry several signature layers, so a
393
+ // candidate that cannot be evaluated is this candidate's failure and the
394
+ // next one is still tried; the refusal at the end reports them all.
395
+ try {
396
+ // Verified against the digest that named it, like any other blob.
397
+ const payloadBytes = await fetchBlob({
398
+ registry,
399
+ repository,
400
+ digest: candidate.payloadDigest,
401
+ scheme,
402
+ timeoutMs,
403
+ fetchImpl,
404
+ retryOptions,
405
+ });
406
+
407
+ // Checked BEFORE the cryptographic verification, so a signature lifted
408
+ // from another artifact is refused as misidentified rather than reported
409
+ // as a verification failure.
410
+ assertPayloadNamesDigest(payloadBytes, digest);
411
+
412
+ await sigstoreVerifier(
413
+ assembleBundle({
414
+ payloadBytes,
415
+ signature: candidate.signature,
416
+ certificate: candidate.certificate,
417
+ tlogEntry,
418
+ }),
419
+ payloadBytes,
420
+ {
421
+ certificateIssuer,
422
+ certificateIdentityURI: toAnchoredIdentityPattern(certificateIdentityURI),
423
+ }
424
+ );
425
+ return { certificateIdentityURI, certificateIssuer };
426
+ } catch (error) {
427
+ failures.push(error instanceof Error ? error.message : String(error));
428
+ }
429
+ }
430
+
431
+ throw new OciRegistryError(
432
+ `No cosign signature for ${registry}/${repository}@${digest} verifies as ` +
433
+ `${certificateIdentityURI}: ${failures.join("; ")}`,
434
+ "misidentified"
435
+ );
436
+ }
437
+
438
+ /**
439
+ * Index an artifact manifest's layers by media type, refusing anything unknown.
440
+ *
441
+ * Fail-closed on an unrecognised media type (C4.3), matching how the tarball
442
+ * path treats an unknown `artifactKind`: a layer this consumer cannot name is a
443
+ * layer it cannot reason about, and installing the rest of the artifact while
444
+ * ignoring it would be deciding that the publisher's addition did not matter.
445
+ */
446
+ export function indexLayersByMediaType(manifest, { repository = "" } = {}) {
447
+ const layers = Array.isArray(manifest?.layers) ? manifest.layers : [];
448
+ const byKind = {};
449
+
450
+ for (const layer of layers) {
451
+ if (!KNOWN_LAYER_MEDIA_TYPES.has(layer?.mediaType)) {
452
+ throw new OciRegistryError(
453
+ `Refusing ${repository}: unrecognised layer media type "${layer?.mediaType}"`,
454
+ "unsupported-layer"
455
+ );
456
+ }
457
+ const kind = Object.keys(OCI_LAYER_MEDIA_TYPES).find(
458
+ (name) => OCI_LAYER_MEDIA_TYPES[name] === layer.mediaType
459
+ );
460
+ if (byKind[kind]) {
461
+ throw new OciRegistryError(
462
+ `Refusing ${repository}: duplicate "${kind}" layer`,
463
+ "unsupported-layer"
464
+ );
465
+ }
466
+ if (!isValidDigest(layer?.digest)) {
467
+ throw new OciRegistryError(
468
+ `Refusing ${repository}: "${kind}" layer has an invalid digest`,
469
+ "tampered"
470
+ );
471
+ }
472
+ byKind[kind] = layer;
473
+ }
474
+
475
+ for (const required of REQUIRED_LAYERS) {
476
+ if (!byKind[required]) {
477
+ throw new OciRegistryError(
478
+ `Refusing ${repository}: artifact has no "${required}" layer`,
479
+ "unsupported-layer"
480
+ );
481
+ }
482
+ }
483
+
484
+ return byKind;
485
+ }
486
+
487
+ /**
488
+ * Cross-check the config blob against the profile it describes (C4.4).
489
+ *
490
+ * The publisher runs this check too, and that does not discharge the
491
+ * obligation: the whole reason the config restates these four fields instead of
492
+ * pointing at them is that two independently stored copies can be compared. A
493
+ * consumer that trusts the publisher ran the check is storing them twice for
494
+ * no reason.
495
+ */
496
+ export function assertConfigMatchesProfile({ config, profileBytes, profile, repository = "" }) {
497
+ const profileDigest = sha256Digest(profileBytes);
498
+ if (config?.profile_digest !== profileDigest) {
499
+ throw new OciRegistryError(
500
+ `Refusing ${repository}: config.profile_digest is ${config?.profile_digest}, ` +
501
+ `but the profile layer hashes to ${profileDigest}`,
502
+ "tampered"
503
+ );
504
+ }
505
+
506
+ for (const field of ["connector_key", "connector_id", "protocol_version", "version"]) {
507
+ if (config?.[field] !== profile?.[field]) {
508
+ throw new OciRegistryError(
509
+ `Refusing ${repository}: config.${field} is ${JSON.stringify(config?.[field])}, ` +
510
+ `but the profile declares ${JSON.stringify(profile?.[field])}`,
511
+ "tampered"
512
+ );
513
+ }
514
+ }
515
+ }
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@pdpp/connector-manager",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "Resolves, verifies, pins and installs signed connector artifacts from a signed index or OCI registry, and fetches signed discovery catalogs.",
6
+ "license": "Apache-2.0",
7
+ "type": "module",
8
+ "main": "./index.mjs",
9
+ "exports": {
10
+ ".": "./index.mjs",
11
+ "./oci-registry.mjs": "./oci-registry.mjs",
12
+ "./oci-verify.mjs": "./oci-verify.mjs",
13
+ "./oci-catalog.mjs": "./oci-catalog.mjs",
14
+ "./catalog-schema.mjs": "./catalog-schema.mjs",
15
+ "./package.json": "./package.json"
16
+ },
17
+ "files": [
18
+ "index.mjs",
19
+ "retry.mjs",
20
+ "oci-registry.mjs",
21
+ "oci-verify.mjs",
22
+ "tar-stream.mjs",
23
+ "README.md",
24
+ "LICENSE",
25
+ "NOTICE",
26
+ "oci-catalog.mjs",
27
+ "catalog-schema.mjs",
28
+ "catalog-schema-data.mjs"
29
+ ],
30
+ "engines": {
31
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
32
+ },
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/PDP-Connect/data-connectors.git",
36
+ "directory": "packages/connector-installer-core"
37
+ },
38
+ "homepage": "https://github.com/PDP-Connect/data-connectors/tree/main/packages/connector-installer-core#readme",
39
+ "bugs": {
40
+ "url": "https://github.com/PDP-Connect/data-connectors/issues"
41
+ },
42
+ "dependencies": {
43
+ "sigstore": "^5.0.0",
44
+ "@sigstore/bundle": "^5.0.0",
45
+ "ajv": "^8.20.0",
46
+ "tar": "^7.5.22"
47
+ },
48
+ "scripts": {
49
+ "test": "node --test ./index.test.mjs"
50
+ }
51
+ }