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.
- package/README.md +1 -0
- package/dist/cli/auth.d.ts +2 -0
- package/dist/cli/auth.d.ts.map +1 -1
- package/dist/cli/auth.js +2 -1
- package/dist/cli/auth.js.map +1 -1
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +128 -13
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/server-start.d.ts +42 -0
- package/dist/cli/server-start.d.ts.map +1 -0
- package/dist/cli/server-start.js +189 -0
- package/dist/cli/server-start.js.map +1 -0
- package/dist/connectors/registry.d.ts +2 -0
- package/dist/connectors/registry.d.ts.map +1 -1
- package/dist/connectors/registry.js +18 -2
- package/dist/connectors/registry.js.map +1 -1
- package/dist/core/cli-types.d.ts +44 -0
- package/dist/core/cli-types.d.ts.map +1 -1
- package/dist/core/cli-types.js +14 -0
- package/dist/core/cli-types.js.map +1 -1
- package/dist/core/state-store.d.ts +6 -0
- package/dist/core/state-store.d.ts.map +1 -1
- package/dist/core/state-store.js.map +1 -1
- package/dist/pdpp/host.d.ts +66 -0
- package/dist/pdpp/host.d.ts.map +1 -0
- package/dist/pdpp/host.js +283 -0
- package/dist/pdpp/host.js.map +1 -0
- package/dist/pdpp/installer.d.ts +17 -0
- package/dist/pdpp/installer.d.ts.map +1 -0
- package/dist/pdpp/installer.js +117 -0
- package/dist/pdpp/installer.js.map +1 -0
- package/dist/pdpp/local.d.ts +14 -0
- package/dist/pdpp/local.d.ts.map +1 -0
- package/dist/pdpp/local.js +76 -0
- package/dist/pdpp/local.js.map +1 -0
- package/dist/pdpp/pins.d.ts +23 -0
- package/dist/pdpp/pins.d.ts.map +1 -0
- package/dist/pdpp/pins.js +18 -0
- package/dist/pdpp/pins.js.map +1 -0
- package/dist/pdpp/profile.d.ts +49 -0
- package/dist/pdpp/profile.d.ts.map +1 -0
- package/dist/pdpp/profile.js +17 -0
- package/dist/pdpp/profile.js.map +1 -0
- package/dist/pdpp/protocol.d.ts +79 -0
- package/dist/pdpp/protocol.d.ts.map +1 -0
- package/dist/pdpp/protocol.js +372 -0
- package/dist/pdpp/protocol.js.map +1 -0
- package/dist/pdpp/runtime.d.ts +43 -0
- package/dist/pdpp/runtime.d.ts.map +1 -0
- package/dist/pdpp/runtime.js +387 -0
- package/dist/pdpp/runtime.js.map +1 -0
- package/dist/pdpp/store.d.ts +43 -0
- package/dist/pdpp/store.d.ts.map +1 -0
- package/dist/pdpp/store.js +100 -0
- package/dist/pdpp/store.js.map +1 -0
- package/dist/personal-server/index.d.ts +1 -0
- package/dist/personal-server/index.d.ts.map +1 -1
- package/dist/personal-server/index.js +17 -2
- package/dist/personal-server/index.js.map +1 -1
- package/dist/personal-server/local/config.d.ts +23 -0
- package/dist/personal-server/local/config.d.ts.map +1 -0
- package/dist/personal-server/local/config.js +51 -0
- package/dist/personal-server/local/config.js.map +1 -0
- package/dist/personal-server/local/owner-binding.d.ts +33 -0
- package/dist/personal-server/local/owner-binding.d.ts.map +1 -0
- package/dist/personal-server/local/owner-binding.js +155 -0
- package/dist/personal-server/local/owner-binding.js.map +1 -0
- package/dist/personal-server/local/owner-secret.d.ts +17 -0
- package/dist/personal-server/local/owner-secret.d.ts.map +1 -0
- package/dist/personal-server/local/owner-secret.js +119 -0
- package/dist/personal-server/local/owner-secret.js.map +1 -0
- package/dist/personal-server/local/runtime-pkg/entry.mjs +153 -0
- package/dist/personal-server/local/runtime-pkg/package-lock.json +2342 -0
- package/dist/personal-server/local/runtime-pkg/package.json +13 -0
- package/dist/personal-server/local/runtime.d.ts +9 -0
- package/dist/personal-server/local/runtime.d.ts.map +1 -0
- package/dist/personal-server/local/runtime.js +66 -0
- package/dist/personal-server/local/runtime.js.map +1 -0
- package/dist/personal-server/local/server.d.ts +39 -0
- package/dist/personal-server/local/server.d.ts.map +1 -0
- package/dist/personal-server/local/server.js +203 -0
- package/dist/personal-server/local/server.js.map +1 -0
- package/dist/runtime/core/contracts.d.ts +2 -0
- package/dist/runtime/core/contracts.d.ts.map +1 -1
- package/dist/runtime/managed-playwright.d.ts +7 -0
- package/dist/runtime/managed-playwright.d.ts.map +1 -1
- package/dist/runtime/managed-playwright.js +7 -0
- package/dist/runtime/managed-playwright.js.map +1 -1
- package/dist/vendor/pdpp-connector-manager/LICENSE +202 -0
- package/dist/vendor/pdpp-connector-manager/NOTICE +2 -0
- package/dist/vendor/pdpp-connector-manager/VENDOR.md +27 -0
- package/dist/vendor/pdpp-connector-manager/catalog-schema-data.mjs +168 -0
- package/dist/vendor/pdpp-connector-manager/catalog-schema.mjs +62 -0
- package/dist/vendor/pdpp-connector-manager/index.d.mts +49 -0
- package/dist/vendor/pdpp-connector-manager/index.mjs +1646 -0
- package/dist/vendor/pdpp-connector-manager/oci-catalog.mjs +93 -0
- package/dist/vendor/pdpp-connector-manager/oci-registry.mjs +1002 -0
- package/dist/vendor/pdpp-connector-manager/oci-verify.mjs +515 -0
- package/dist/vendor/pdpp-connector-manager/package.json +51 -0
- package/dist/vendor/pdpp-connector-manager/retry.mjs +142 -0
- package/dist/vendor/pdpp-connector-manager/tar-stream.mjs +685 -0
- 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
|
+
}
|