@pipobscure/bundle 0.0.1
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/HISTORY.md +1924 -0
- package/README.md +623 -0
- package/bundle.run +0 -0
- package/dist/api.d.ts +147 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +174 -0
- package/dist/api.js.map +1 -0
- package/dist/archive.d.ts +115 -0
- package/dist/archive.d.ts.map +1 -0
- package/dist/archive.js +188 -0
- package/dist/archive.js.map +1 -0
- package/dist/audit.d.ts +78 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +119 -0
- package/dist/audit.js.map +1 -0
- package/dist/cli.d.ts +23 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +555 -0
- package/dist/cli.js.map +1 -0
- package/dist/files.d.ts +53 -0
- package/dist/files.d.ts.map +1 -0
- package/dist/files.js +118 -0
- package/dist/files.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +35 -0
- package/dist/index.js.map +1 -0
- package/dist/launch.d.ts +97 -0
- package/dist/launch.d.ts.map +1 -0
- package/dist/launch.js +267 -0
- package/dist/launch.js.map +1 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +19 -0
- package/dist/main.js.map +1 -0
- package/dist/manifest.d.ts +139 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +504 -0
- package/dist/manifest.js.map +1 -0
- package/dist/oidc.d.ts +40 -0
- package/dist/oidc.d.ts.map +1 -0
- package/dist/oidc.js +320 -0
- package/dist/oidc.js.map +1 -0
- package/dist/preload.d.ts +14 -0
- package/dist/preload.d.ts.map +1 -0
- package/dist/preload.js +38 -0
- package/dist/preload.js.map +1 -0
- package/dist/provider.d.ts +83 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +206 -0
- package/dist/provider.js.map +1 -0
- package/dist/record.d.ts +2 -0
- package/dist/record.d.ts.map +1 -0
- package/dist/record.js +23 -0
- package/dist/record.js.map +1 -0
- package/dist/recorder.d.ts +64 -0
- package/dist/recorder.d.ts.map +1 -0
- package/dist/recorder.js +111 -0
- package/dist/recorder.js.map +1 -0
- package/dist/register.d.ts +2 -0
- package/dist/register.d.ts.map +1 -0
- package/dist/register.js +28 -0
- package/dist/register.js.map +1 -0
- package/dist/sea.d.ts +97 -0
- package/dist/sea.d.ts.map +1 -0
- package/dist/sea.js +220 -0
- package/dist/sea.js.map +1 -0
- package/dist/sigstore.d.ts +112 -0
- package/dist/sigstore.d.ts.map +1 -0
- package/dist/sigstore.js +385 -0
- package/dist/sigstore.js.map +1 -0
- package/dist/skill.d.ts +36 -0
- package/dist/skill.d.ts.map +1 -0
- package/dist/skill.js +108 -0
- package/dist/skill.js.map +1 -0
- package/package.json +84 -0
- package/shell-base +2 -0
- package/skills/audit-bundle/SKILL.md +271 -0
- package/src/api.ts +293 -0
- package/src/archive.ts +312 -0
- package/src/audit.ts +206 -0
- package/src/cli.ts +575 -0
- package/src/files.ts +156 -0
- package/src/index.ts +114 -0
- package/src/launch.ts +336 -0
- package/src/main.ts +20 -0
- package/src/manifest.ts +615 -0
- package/src/oidc.ts +372 -0
- package/src/preload.ts +40 -0
- package/src/provider.ts +270 -0
- package/src/record.ts +25 -0
- package/src/recorder.ts +166 -0
- package/src/register.ts +30 -0
- package/src/sea.ts +341 -0
- package/src/sigstore.ts +492 -0
- package/src/skill.ts +132 -0
- package/src/types/node-vfs.d.ts +90 -0
- package/src/types/node-zip.d.ts +85 -0
package/src/sigstore.ts
ADDED
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
import * as CRYPTO from 'node:crypto';
|
|
2
|
+
import * as PATH from 'node:path';
|
|
3
|
+
import * as OS from 'node:os';
|
|
4
|
+
import * as FS from 'node:fs';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import { identityToken, FULCIO_AUDIENCE } from './oidc.ts';
|
|
7
|
+
import type { Signer } from './archive.ts';
|
|
8
|
+
|
|
9
|
+
import type * as SigstoreBundle from '@sigstore/bundle';
|
|
10
|
+
import type * as SigstoreSign from '@sigstore/sign';
|
|
11
|
+
import type * as SigstoreVerify from '@sigstore/verify';
|
|
12
|
+
import type * as SigstoreSpecs from '@sigstore/protobuf-specs';
|
|
13
|
+
import type * as SigstoreTuf from '@sigstore/tuf';
|
|
14
|
+
|
|
15
|
+
// Sigstore as one of the signers this format can carry, rather than as the
|
|
16
|
+
// format itself.
|
|
17
|
+
//
|
|
18
|
+
// Everything the archive needs is still the same two things: a certificate
|
|
19
|
+
// chain in `AUTHORITY.PEM` and a signature over the whole-file hash in the
|
|
20
|
+
// EOCD comment. Sigstore only changes where the certificate comes from — a
|
|
21
|
+
// ten-minute Fulcio certificate bound to an OIDC identity, instead of a
|
|
22
|
+
// long-lived key on someone's disk — and adds one field to the comment.
|
|
23
|
+
//
|
|
24
|
+
// ## The ordering problem, and how the two-phase signer solves it
|
|
25
|
+
//
|
|
26
|
+
// `AUTHORITY.PEM` is a member, so it is inside the hashed region: the chain has
|
|
27
|
+
// to be known *before* the hash exists. The signature has to be made *after*.
|
|
28
|
+
// Sigstore's own `BundleBuilder` does both in one `create()` call, which cannot
|
|
29
|
+
// work here.
|
|
30
|
+
//
|
|
31
|
+
// It does not have to. A Fulcio certificate binds an *identity to a public
|
|
32
|
+
// key*; it says nothing about any message. So the two halves separate cleanly:
|
|
33
|
+
//
|
|
34
|
+
// 1. `signer()` — sign in, mint a keypair, get the certificate. No archive
|
|
35
|
+
// needed, and nothing here depends on what is being signed.
|
|
36
|
+
// 2. `sign(digest)` — sign the finished hash with that key, then hand the
|
|
37
|
+
// signature to Rekor and the timestamp authority for witnessing.
|
|
38
|
+
//
|
|
39
|
+
// Between them, the caller builds the archive with the chain from step 1 and
|
|
40
|
+
// hashes it. `archive.ts` drives exactly that sequence.
|
|
41
|
+
//
|
|
42
|
+
// ## Why the witnesses matter here more than usual
|
|
43
|
+
//
|
|
44
|
+
// A Fulcio certificate expires about ten minutes after it is issued, so
|
|
45
|
+
// `anchored()`'s "is this chain in date *now*?" question gives the wrong answer
|
|
46
|
+
// for every archive older than that. The right question is whether the
|
|
47
|
+
// certificate was valid *when the signature was made*, which needs a
|
|
48
|
+
// trustworthy assertion of when that was — a Rekor log entry, an RFC 3161
|
|
49
|
+
// token, or both. `Verifier` checks the signature against the signing time
|
|
50
|
+
// those establish, which is why the sigstore path replaces the plain X.509
|
|
51
|
+
// anchoring rather than adding to it.
|
|
52
|
+
//
|
|
53
|
+
// The bundle carrying that material rides in the EOCD comment as the
|
|
54
|
+
// `SIGSTORE=` field: it is produced after the signature, so it cannot live in
|
|
55
|
+
// the hashed region — the same reason RFC 3161 puts timestamp tokens in CMS
|
|
56
|
+
// `unsignedAttrs`. See HISTORY.md, "Implementation notes" §1.
|
|
57
|
+
|
|
58
|
+
const require = createRequire(import.meta.url);
|
|
59
|
+
|
|
60
|
+
export const DEFAULT_FULCIO_URL = 'https://fulcio.sigstore.dev';
|
|
61
|
+
export const DEFAULT_REKOR_URL = 'https://rekor.sigstore.dev';
|
|
62
|
+
export const DEFAULT_TSA_URL = 'https://timestamp.sigstore.dev/api/v1/timestamp';
|
|
63
|
+
export const DEFAULT_TUF_MIRROR = 'https://tuf-repo-cdn.sigstore.dev';
|
|
64
|
+
|
|
65
|
+
/** The comment field the sigstore bundle travels in. */
|
|
66
|
+
export const FIELD = 'SIGSTORE';
|
|
67
|
+
|
|
68
|
+
/** Where a trust root came from, so a caller can say how fresh it is. */
|
|
69
|
+
export type TrustedRootOrigin = 'explicit' | 'environment' | 'cache' | 'seed';
|
|
70
|
+
|
|
71
|
+
export interface TrustedRootLocation {
|
|
72
|
+
origin: TrustedRootOrigin;
|
|
73
|
+
/** The file it was read from, or the seed's mirror key. */
|
|
74
|
+
path: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface SigstoreIdentity {
|
|
78
|
+
subject?: string | undefined;
|
|
79
|
+
issuer?: string | undefined;
|
|
80
|
+
/** GitHub Actions tokens name the workflow that ran. */
|
|
81
|
+
workflow?: string | undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface SigstoreSignerOptions {
|
|
85
|
+
/** An OIDC token to use instead of signing in. */
|
|
86
|
+
token?: string | undefined;
|
|
87
|
+
flow?: 'auto' | 'ci' | 'browser' | 'device' | undefined;
|
|
88
|
+
/** OIDC issuer (default: sigstore's Dex). */
|
|
89
|
+
issuer?: string | undefined;
|
|
90
|
+
/** Dex connector to jump to (default: 'github'). */
|
|
91
|
+
connector?: string | undefined;
|
|
92
|
+
fulcioURL?: string | undefined;
|
|
93
|
+
/** Transparency log; '' to skip. */
|
|
94
|
+
rekorURL?: string | undefined;
|
|
95
|
+
/** RFC 3161 timestamp authority; '' to skip. */
|
|
96
|
+
tsaURL?: string | undefined;
|
|
97
|
+
/** Digest the signature uses (default: 'sha256'). */
|
|
98
|
+
signAlg?: string | undefined;
|
|
99
|
+
log?: ((line: string) => void) | undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A sigstore-backed `Signer`, with the identity it authenticated as. */
|
|
103
|
+
export interface SigstoreSigner extends Signer {
|
|
104
|
+
kind: 'sigstore';
|
|
105
|
+
identity: SigstoreIdentity;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface BundleVerification {
|
|
109
|
+
identity?: string | undefined;
|
|
110
|
+
issuer?: string | undefined;
|
|
111
|
+
signedAt?: Date | undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// The sigstore libraries are an optional dependency of *verification*: an
|
|
115
|
+
// archive signed against an ordinary CA verifies with nothing but `node:crypto`,
|
|
116
|
+
// and the mount path must not hard-fail because a signing-side package is
|
|
117
|
+
// absent. Loading is therefore lazy and its failure is a reported reason rather
|
|
118
|
+
// than a thrown error.
|
|
119
|
+
function load<T>(name: string): T {
|
|
120
|
+
try {
|
|
121
|
+
return require(name) as T;
|
|
122
|
+
} catch (err) {
|
|
123
|
+
throw Object.assign(new Error(
|
|
124
|
+
`sigstore support needs '${name}' — install this package's dependencies`),
|
|
125
|
+
{ code: 'ERR_BUNDLE_SIGSTORE_UNAVAILABLE', cause: err });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Whether the sigstore libraries needed for *verification* are loadable. */
|
|
130
|
+
export function available(): boolean {
|
|
131
|
+
try {
|
|
132
|
+
require.resolve('@sigstore/verify');
|
|
133
|
+
require.resolve('@sigstore/bundle');
|
|
134
|
+
require.resolve('@sigstore/protobuf-specs');
|
|
135
|
+
return true;
|
|
136
|
+
} catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------- signing ---
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Phase one: authenticate, mint an ephemeral keypair, and get a Fulcio
|
|
145
|
+
* certificate for it. Returns a signer whose `chain` can go straight into
|
|
146
|
+
* `AUTHORITY.PEM` and whose `sign()` finishes the job once the hash exists.
|
|
147
|
+
*/
|
|
148
|
+
export async function signer(options: SigstoreSignerOptions = {}): Promise<SigstoreSigner> {
|
|
149
|
+
const log = options.log ?? ((line: string) => { process.stderr.write(`${line}\n`); });
|
|
150
|
+
const signAlg = options.signAlg ?? 'sha256';
|
|
151
|
+
|
|
152
|
+
const { token, flow } = await identityToken({ ...options, audience: FULCIO_AUDIENCE, log });
|
|
153
|
+
const identity = describe(token);
|
|
154
|
+
log(` identity: ${identity.subject ?? '(unknown)'} (${identity.issuer ?? 'unknown issuer'}${flow === 'ci' ? ', CI' : ''})`);
|
|
155
|
+
|
|
156
|
+
// The keypair never leaves this process and is discarded when it exits.
|
|
157
|
+
// That is the point of the short certificate lifetime: there is no
|
|
158
|
+
// long-lived signing key for anyone to steal later.
|
|
159
|
+
const keypair = CRYPTO.generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
|
160
|
+
const chain = await certify(token, keypair, options.fulcioURL ?? DEFAULT_FULCIO_URL);
|
|
161
|
+
log(` certificate: ${chain.length} in chain, expires ${new CRYPTO.X509Certificate(chain[0]!).validTo}`);
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
kind: 'sigstore',
|
|
165
|
+
identity,
|
|
166
|
+
// Leaf first, then the intermediates Fulcio returned — the same shape
|
|
167
|
+
// `--chain` takes, so `AUTHORITY.PEM` looks identical either way and
|
|
168
|
+
// `unzip` + `openssl x509` still tells you who signed it.
|
|
169
|
+
chain: chain.join(''),
|
|
170
|
+
signAlg,
|
|
171
|
+
|
|
172
|
+
// Phase two. `digest` is the whole-file hash, already computed over an
|
|
173
|
+
// archive built with the chain above.
|
|
174
|
+
async sign(digest: Buffer) {
|
|
175
|
+
const signature = CRYPTO.sign(signAlg, digest, keypair.privateKey);
|
|
176
|
+
const bundle = await witness({
|
|
177
|
+
artifact: digest,
|
|
178
|
+
signature,
|
|
179
|
+
certificate: chain[0]!,
|
|
180
|
+
rekorURL: options.rekorURL ?? DEFAULT_REKOR_URL,
|
|
181
|
+
tsaURL: options.tsaURL ?? DEFAULT_TSA_URL,
|
|
182
|
+
log,
|
|
183
|
+
});
|
|
184
|
+
return {
|
|
185
|
+
signature,
|
|
186
|
+
fields: { [FIELD]: Buffer.from(JSON.stringify(bundle), 'utf-8').toString('base64') },
|
|
187
|
+
};
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Exchange an OIDC token plus a public key for a certificate chain, PEM,
|
|
193
|
+
// leaf first. Fulcio's proof-of-possession is a signature over the token's
|
|
194
|
+
// subject claim, which is what ties the key to the identity.
|
|
195
|
+
async function certify(token: string, keypair: KeyPair, fulcioURL: string): Promise<string[]> {
|
|
196
|
+
const subject = challenge(claims(token));
|
|
197
|
+
if (!subject) throw new Error('identity token carries no subject claim');
|
|
198
|
+
|
|
199
|
+
const publicKey = keypair.publicKey.export({ format: 'pem', type: 'spki' }).toString();
|
|
200
|
+
const proof = CRYPTO.sign('sha256', Buffer.from(String(subject)), keypair.privateKey);
|
|
201
|
+
|
|
202
|
+
const res = await fetch(`${fulcioURL.replace(/\/+$/, '')}/api/v2/signingCert`, {
|
|
203
|
+
method: 'POST',
|
|
204
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
|
205
|
+
body: JSON.stringify({
|
|
206
|
+
credentials: { oidcIdentityToken: token },
|
|
207
|
+
publicKeyRequest: {
|
|
208
|
+
publicKey: { algorithm: 'ECDSA', content: publicKey },
|
|
209
|
+
proofOfPossession: proof.toString('base64'),
|
|
210
|
+
},
|
|
211
|
+
}),
|
|
212
|
+
});
|
|
213
|
+
if (!res.ok) {
|
|
214
|
+
throw new Error(`fulcio refused to issue a certificate: ${res.status} ${await res.text().catch(() => res.statusText)}`);
|
|
215
|
+
}
|
|
216
|
+
const body = await res.json() as FulcioResponse;
|
|
217
|
+
// Fulcio answers with an embedded or a detached SCT depending on
|
|
218
|
+
// configuration; the chain is in the same place either way.
|
|
219
|
+
const certs = (body.signedCertificateEmbeddedSct ?? body.signedCertificateDetachedSct)?.chain?.certificates;
|
|
220
|
+
if (!certs?.length) throw new Error('fulcio returned no certificate chain');
|
|
221
|
+
return certs.map((pem) => (pem.endsWith('\n') ? pem : `${pem}\n`));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// What Fulcio expects the proof of possession to sign: the principal it will
|
|
225
|
+
// certify. For the email-based issuers that is the `email` claim — `sub` there
|
|
226
|
+
// is an opaque account id, and a proof over it is refused as unverifiable. For
|
|
227
|
+
// everything else, CI tokens included, it is `sub`. The same rule sigstore-js
|
|
228
|
+
// applies.
|
|
229
|
+
const EMAIL_ISSUERS = new Set(['https://oauth2.sigstore.dev/auth', 'https://accounts.google.com']);
|
|
230
|
+
|
|
231
|
+
function challenge(claims: Record<string, unknown>): unknown {
|
|
232
|
+
return EMAIL_ISSUERS.has(String(claims['iss'])) ? claims['email'] : claims['sub'];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** What `generateKeyPairSync('ec', …)` hands back. */
|
|
236
|
+
interface KeyPair {
|
|
237
|
+
publicKey: CRYPTO.KeyObject;
|
|
238
|
+
privateKey: CRYPTO.KeyObject;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
interface FulcioResponse {
|
|
242
|
+
signedCertificateEmbeddedSct?: { chain?: { certificates?: string[] } };
|
|
243
|
+
signedCertificateDetachedSct?: { chain?: { certificates?: string[] } };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Assemble the sigstore bundle and have it witnessed: logged in Rekor, and
|
|
247
|
+
// timestamped by the TSA. Either can be turned off, and a witness that fails is
|
|
248
|
+
// reported but does not sink the signature — the archive is still signed, it
|
|
249
|
+
// just verifies with less evidence about when.
|
|
250
|
+
async function witness({ artifact, signature, certificate, rekorURL, tsaURL, log }: {
|
|
251
|
+
artifact: Buffer;
|
|
252
|
+
signature: Buffer;
|
|
253
|
+
certificate: string;
|
|
254
|
+
rekorURL: string;
|
|
255
|
+
tsaURL: string;
|
|
256
|
+
log: (line: string) => void;
|
|
257
|
+
}): Promise<unknown> {
|
|
258
|
+
const { toMessageSignatureBundle, bundleToJSON } = load<typeof SigstoreBundle>('@sigstore/bundle');
|
|
259
|
+
const { RekorWitness, TSAWitness } = load<typeof SigstoreSign>('@sigstore/sign');
|
|
260
|
+
|
|
261
|
+
// A message-signature bundle records the *digest* of what was signed, not
|
|
262
|
+
// the bytes. What was signed here is already a hash — the archive's — so
|
|
263
|
+
// this is a hash of a hash, and the verifier recomputes it the same way
|
|
264
|
+
// from the same 32 bytes.
|
|
265
|
+
const bundle = toMessageSignatureBundle({
|
|
266
|
+
digest: CRYPTO.createHash('sha256').update(artifact).digest(),
|
|
267
|
+
signature,
|
|
268
|
+
certificate: derFromPEM(certificate),
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const witnesses: [string, SigstoreSign.Witness][] = [];
|
|
272
|
+
if (rekorURL) witnesses.push(['transparency log', new RekorWitness({ rekorBaseURL: rekorURL })]);
|
|
273
|
+
if (tsaURL) witnesses.push(['timestamp authority', new TSAWitness({ tsaBaseURL: tsaURL })]);
|
|
274
|
+
|
|
275
|
+
const tlogEntries: SigstoreBundle.TransparencyLogEntry[] = [];
|
|
276
|
+
const rfc3161Timestamps: NonNullable<SigstoreSign.VerificationMaterial['rfc3161Timestamps']> = [];
|
|
277
|
+
for (const [name, w] of witnesses) {
|
|
278
|
+
try {
|
|
279
|
+
const material = await w.testify(bundle.content, certificate);
|
|
280
|
+
tlogEntries.push(...(material.tlogEntries ?? []));
|
|
281
|
+
rfc3161Timestamps.push(...(material.rfc3161Timestamps ?? []));
|
|
282
|
+
log(` ${name}: recorded`);
|
|
283
|
+
} catch (err) {
|
|
284
|
+
log(` ${name}: unavailable (${err instanceof Error ? err.message : String(err)})`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (!tlogEntries.length && !rfc3161Timestamps.length) {
|
|
288
|
+
throw new Error('no signing-time evidence could be obtained — a sigstore certificate ' +
|
|
289
|
+
'expires in minutes, so an archive with neither a log entry nor a timestamp ' +
|
|
290
|
+
'could never be verified again');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
bundle.verificationMaterial.tlogEntries = tlogEntries;
|
|
294
|
+
bundle.verificationMaterial.timestampVerificationData = { rfc3161Timestamps };
|
|
295
|
+
return bundleToJSON(bundle);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** The DER bytes of a PEM certificate — what the bundle format carries. */
|
|
299
|
+
function derFromPEM(pem: string): Buffer {
|
|
300
|
+
const body = pem.replace(/-----(BEGIN|END) CERTIFICATE-----/g, '').replace(/\s+/g, '');
|
|
301
|
+
return Buffer.from(body, 'base64');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ------------------------------------------------------------ verification ---
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Verify a sigstore bundle over `artifact` (the whole-file hash bytes) and
|
|
308
|
+
* return the identity it establishes. Synchronous, because the mount path is:
|
|
309
|
+
* `provider.ts` decides whether to serve an archive before any of the program
|
|
310
|
+
* in it runs, and cannot await.
|
|
311
|
+
*
|
|
312
|
+
* Throws on a bundle that does not verify. `trustedRoot` must be supplied —
|
|
313
|
+
* this deliberately does not reach for the network.
|
|
314
|
+
*/
|
|
315
|
+
export function verifyBundle(
|
|
316
|
+
encoded: string,
|
|
317
|
+
artifact: Buffer,
|
|
318
|
+
{ trustedRoot, identity, issuer }: {
|
|
319
|
+
trustedRoot: SigstoreSpecs.TrustedRoot;
|
|
320
|
+
identity?: string | undefined;
|
|
321
|
+
issuer?: string | undefined;
|
|
322
|
+
},
|
|
323
|
+
): BundleVerification {
|
|
324
|
+
const { bundleFromJSON } = load<typeof SigstoreBundle>('@sigstore/bundle');
|
|
325
|
+
const { Verifier, toTrustMaterial, toSignedEntity } = load<typeof SigstoreVerify>('@sigstore/verify');
|
|
326
|
+
|
|
327
|
+
const bundle = bundleFromJSON(JSON.parse(Buffer.from(encoded, 'base64').toString('utf-8')));
|
|
328
|
+
const entity = toSignedEntity(bundle, artifact);
|
|
329
|
+
const verifier = new Verifier(toTrustMaterial(trustedRoot));
|
|
330
|
+
|
|
331
|
+
// A policy of `undefined` still verifies the chain, the log entry and the
|
|
332
|
+
// signing time; it only skips the "and it must be *this* identity" check,
|
|
333
|
+
// which is the caller's to impose.
|
|
334
|
+
//
|
|
335
|
+
// The identity is matched exactly. `@sigstore/verify` hands the policy to
|
|
336
|
+
// `String.prototype.match`, which makes it an unanchored regular expression:
|
|
337
|
+
// `pip@pip.fyi` would accept a certificate for `pip@pip-fyi.com`, a domain
|
|
338
|
+
// anyone can register. Escaped and anchored, it means what it says.
|
|
339
|
+
const policy = identity || issuer
|
|
340
|
+
? { ...(identity ? { subjectAlternativeName: exactly(identity) } : {}), ...(issuer ? { extensions: { issuer } } : {}) }
|
|
341
|
+
: undefined;
|
|
342
|
+
const signer = verifier.verify(entity, policy);
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
identity: signer.identity?.subjectAlternativeName,
|
|
346
|
+
issuer: signer.identity?.extensions?.issuer,
|
|
347
|
+
signedAt: signingTime(bundle),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* The leaf certificate a sigstore bundle carries, as an X509Certificate — so a
|
|
353
|
+
* verifier can confirm the inspectable `AUTHORITY.PEM` names the same
|
|
354
|
+
* certificate the bundle was actually verified against.
|
|
355
|
+
*/
|
|
356
|
+
export function bundleCertificate(encoded: string): CRYPTO.X509Certificate | null {
|
|
357
|
+
const { bundleFromJSON } = load<typeof SigstoreBundle>('@sigstore/bundle');
|
|
358
|
+
const bundle = bundleFromJSON(JSON.parse(Buffer.from(encoded, 'base64').toString('utf-8')));
|
|
359
|
+
const content = bundle.verificationMaterial?.content;
|
|
360
|
+
const der = content?.$case === 'certificate' ? content.certificate.rawBytes
|
|
361
|
+
: content?.$case === 'x509CertificateChain' ? content.x509CertificateChain.certificates[0]?.rawBytes
|
|
362
|
+
: undefined;
|
|
363
|
+
return der ? new CRYPTO.X509Certificate(Buffer.from(der)) : null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// When the signature was witnessed, as a Date — the log entry's integrated time
|
|
367
|
+
// if there is one. Reported for the record; `Verifier` has already used the
|
|
368
|
+
// same material to decide the certificate was in date.
|
|
369
|
+
function signingTime(bundle: SigstoreBundle.Bundle): Date | undefined {
|
|
370
|
+
const seconds = bundle.verificationMaterial?.tlogEntries?.[0]?.integratedTime;
|
|
371
|
+
return seconds ? new Date(Number(seconds) * 1000) : undefined;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ------------------------------------------------------------- trust root ---
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Where the sigstore trust root would be read from, in the order tried:
|
|
378
|
+
* an explicit path, `BUNDLE_SIGSTORE_ROOT`, the cache `bundle trust` maintains,
|
|
379
|
+
* and finally the seed `@sigstore/tuf` ships. Returns null when none exists.
|
|
380
|
+
*
|
|
381
|
+
* The seed is the fallback rather than the first choice because it is frozen at
|
|
382
|
+
* the version of the library that was installed; `bundle trust` fetches the
|
|
383
|
+
* live one over TUF and that copy wins whenever it is present.
|
|
384
|
+
*/
|
|
385
|
+
export function trustedRootLocation(source?: string | undefined): TrustedRootLocation | null {
|
|
386
|
+
if (source) return { origin: 'explicit', path: source };
|
|
387
|
+
const fromEnv = process.env['BUNDLE_SIGSTORE_ROOT'];
|
|
388
|
+
if (fromEnv) return { origin: 'environment', path: fromEnv };
|
|
389
|
+
const cached = cachedRootPath();
|
|
390
|
+
if (cached && FS.existsSync(cached)) return { origin: 'cache', path: cached };
|
|
391
|
+
if (seedRootPath()) return { origin: 'seed', path: DEFAULT_TUF_MIRROR };
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Load the sigstore trust root synchronously. Returns null when there is none —
|
|
397
|
+
* verification then reports that as a reason rather than failing, because
|
|
398
|
+
* "I could not check" and "this is forged" are different answers.
|
|
399
|
+
*/
|
|
400
|
+
export function trustedRootSync(source?: string | undefined): SigstoreSpecs.TrustedRoot | null {
|
|
401
|
+
const where = trustedRootLocation(source);
|
|
402
|
+
if (!where) return null;
|
|
403
|
+
try {
|
|
404
|
+
const { TrustedRoot } = load<typeof SigstoreSpecs>('@sigstore/protobuf-specs');
|
|
405
|
+
const json = where.origin === 'seed'
|
|
406
|
+
? seedTrustedRootJSON(where.path)
|
|
407
|
+
: JSON.parse(FS.readFileSync(where.path, 'utf-8')) as unknown;
|
|
408
|
+
return json ? TrustedRoot.fromJSON(json) : null;
|
|
409
|
+
} catch {
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Refresh the trust root over the network, through TUF — signed metadata with
|
|
416
|
+
* its own root of trust, not a plain download. Returns the cache path.
|
|
417
|
+
*/
|
|
418
|
+
export async function refreshTrustedRoot({ mirror = DEFAULT_TUF_MIRROR, force = true }: {
|
|
419
|
+
mirror?: string | undefined;
|
|
420
|
+
force?: boolean | undefined;
|
|
421
|
+
} = {}): Promise<string> {
|
|
422
|
+
const { getTrustedRoot } = load<typeof SigstoreTuf>('@sigstore/tuf');
|
|
423
|
+
await getTrustedRoot({ mirrorURL: mirror, force });
|
|
424
|
+
return cachedRootPath(mirror);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Where `@sigstore/tuf` keeps the target it downloaded. Mirrors its own
|
|
429
|
+
* `appDataPath()` so the two agree without depending on a private export.
|
|
430
|
+
*/
|
|
431
|
+
export function cachedRootPath(mirror: string = DEFAULT_TUF_MIRROR): string {
|
|
432
|
+
const home = OS.homedir();
|
|
433
|
+
const base = process.platform === 'darwin' ? PATH.join(home, 'Library', 'Application Support', 'sigstore-js')
|
|
434
|
+
: process.platform === 'win32' ? PATH.join(process.env['LOCALAPPDATA'] || PATH.join(home, 'AppData', 'Local'), 'sigstore-js', 'Data')
|
|
435
|
+
: PATH.join(process.env['XDG_DATA_HOME'] || PATH.join(home, '.local', 'share'), 'sigstore-js');
|
|
436
|
+
return PATH.join(base, new URL(mirror).host, 'targets', 'trusted_root.json');
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// The trust root `@sigstore/tuf` ships alongside its own TUF root, so a machine
|
|
440
|
+
// that has never run `bundle trust` can still check a sigstore signature
|
|
441
|
+
// offline. It is only ever the fallback; a refreshed cache takes precedence.
|
|
442
|
+
function seedRootPath(): string | null {
|
|
443
|
+
try {
|
|
444
|
+
return require.resolve('@sigstore/tuf/seeds.json');
|
|
445
|
+
} catch {
|
|
446
|
+
try {
|
|
447
|
+
return PATH.join(PATH.dirname(require.resolve('@sigstore/tuf')), '..', 'seeds.json');
|
|
448
|
+
} catch {
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function seedTrustedRootJSON(mirror: string): unknown {
|
|
455
|
+
const path = seedRootPath();
|
|
456
|
+
if (!path || !FS.existsSync(path)) return null;
|
|
457
|
+
const seeds = JSON.parse(FS.readFileSync(path, 'utf-8')) as Record<string, { targets?: Record<string, string> }>;
|
|
458
|
+
const encoded = seeds[mirror]?.targets?.['trusted_root.json'];
|
|
459
|
+
return encoded ? JSON.parse(Buffer.from(encoded, 'base64').toString('utf-8')) : null;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* A policy string that matches `value` and nothing else, for a verifier that
|
|
464
|
+
* reads its policy as a regular expression.
|
|
465
|
+
*/
|
|
466
|
+
export function exactly(value: string): string {
|
|
467
|
+
return `^${value.replace(/[\\^$.*+?()[\]{}|/-]/g, '\\$&')}$`;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ------------------------------------------------------------------ tokens ---
|
|
471
|
+
|
|
472
|
+
// A JWT's payload. Unverified — Fulcio is what validates the token; this is
|
|
473
|
+
// only ever used to show the user which identity is about to be certified.
|
|
474
|
+
function claims(token: string): Record<string, unknown> {
|
|
475
|
+
try {
|
|
476
|
+
return JSON.parse(Buffer.from(String(token).split('.')[1] ?? '', 'base64url').toString('utf-8')) as Record<string, unknown>;
|
|
477
|
+
} catch {
|
|
478
|
+
return {};
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function describe(token: string): SigstoreIdentity {
|
|
483
|
+
const c = claims(token);
|
|
484
|
+
const str = (value: unknown) => (typeof value === 'string' ? value : undefined);
|
|
485
|
+
return {
|
|
486
|
+
subject: str(c['email']) ?? str(c['sub']),
|
|
487
|
+
issuer: str(c['iss']),
|
|
488
|
+
// GitHub Actions tokens name the workflow that ran; that is the part
|
|
489
|
+
// worth showing, since it is the identity a release artifact carries.
|
|
490
|
+
workflow: str(c['workflow_ref']) ?? str(c['job_workflow_ref']),
|
|
491
|
+
};
|
|
492
|
+
}
|
package/src/skill.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import * as FS from 'node:fs';
|
|
2
|
+
import * as PATH from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
// The Claude Code skills this package ships, and installing them into a working
|
|
6
|
+
// directory.
|
|
7
|
+
//
|
|
8
|
+
// A signature answers *who produced these bytes*; it does not answer *are these
|
|
9
|
+
// bytes safe*. `audit-bundle` is the second question written down as a
|
|
10
|
+
// procedure — verify, extract, review — and it belongs next to whoever is about
|
|
11
|
+
// to run an archive rather than in this repository, so the CLI can write it
|
|
12
|
+
// out: `bundle skill` drops it into `.claude/skills/` where Claude Code will
|
|
13
|
+
// find it.
|
|
14
|
+
//
|
|
15
|
+
// The skill files are ordinary package members, read from disk relative to this
|
|
16
|
+
// module. That holds inside a mounted bundle too, as long as they were recorded
|
|
17
|
+
// in the manifest the bundle was built from — which is why the build's
|
|
18
|
+
// observation run installs a skill.
|
|
19
|
+
|
|
20
|
+
/** Where a skill's files live in this package. */
|
|
21
|
+
const ROOT = new URL('../skills/', import.meta.url);
|
|
22
|
+
|
|
23
|
+
/** The conventional place Claude Code looks for project-local skills. */
|
|
24
|
+
export const DEFAULT_SKILLS_DIR = PATH.join('.claude', 'skills');
|
|
25
|
+
|
|
26
|
+
export interface SkillInfo {
|
|
27
|
+
/** Directory name, which is also the name Claude Code invokes it by. */
|
|
28
|
+
name: string;
|
|
29
|
+
/** The `description:` from the skill's front matter, when it has one. */
|
|
30
|
+
description: string;
|
|
31
|
+
/** Absolute path to the skill's directory inside this package. */
|
|
32
|
+
source: string;
|
|
33
|
+
/** File names the skill consists of, relative to its directory. */
|
|
34
|
+
files: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface InstallResult {
|
|
38
|
+
name: string;
|
|
39
|
+
/** Absolute path of the installed skill's directory. */
|
|
40
|
+
path: string;
|
|
41
|
+
/** Absolute paths of the files written. */
|
|
42
|
+
written: string[];
|
|
43
|
+
/** Absolute paths of files that already existed and were left alone. */
|
|
44
|
+
skipped: string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Every skill this package carries, in name order. */
|
|
48
|
+
export function skills(): SkillInfo[] {
|
|
49
|
+
const dir = fileURLToPath(ROOT);
|
|
50
|
+
let names: string[];
|
|
51
|
+
try {
|
|
52
|
+
names = FS.readdirSync(dir).sort();
|
|
53
|
+
} catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
const found: SkillInfo[] = [];
|
|
57
|
+
for (const name of names) {
|
|
58
|
+
const source = PATH.join(dir, name);
|
|
59
|
+
let files: string[];
|
|
60
|
+
try {
|
|
61
|
+
if (!FS.statSync(source).isDirectory()) continue;
|
|
62
|
+
files = walk(source);
|
|
63
|
+
} catch {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (!files.includes('SKILL.md')) continue;
|
|
67
|
+
found.push({ name, description: describe(PATH.join(source, 'SKILL.md')), source, files });
|
|
68
|
+
}
|
|
69
|
+
return found;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** One skill by name, or null when this package does not carry it. */
|
|
73
|
+
export function skill(name: string): SkillInfo | null {
|
|
74
|
+
return skills().find((entry) => entry.name === name) ?? null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Write a skill into `dir` (default `.claude/skills`), as `<dir>/<name>/…`.
|
|
79
|
+
*
|
|
80
|
+
* Existing files are left alone unless `force` is set, so re-running this never
|
|
81
|
+
* silently discards local edits; what was skipped comes back in the result.
|
|
82
|
+
*/
|
|
83
|
+
export function install(name: string, { dir = DEFAULT_SKILLS_DIR, force = false }: {
|
|
84
|
+
dir?: string | undefined;
|
|
85
|
+
force?: boolean | undefined;
|
|
86
|
+
} = {}): InstallResult {
|
|
87
|
+
const found = skill(name);
|
|
88
|
+
if (!found) {
|
|
89
|
+
const known = skills().map((entry) => entry.name);
|
|
90
|
+
throw new Error(`unknown skill: ${name}${known.length ? ` (this package carries: ${known.join(', ')})` : ''}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const target = PATH.resolve(dir, name);
|
|
94
|
+
const written: string[] = [];
|
|
95
|
+
const skipped: string[] = [];
|
|
96
|
+
for (const file of found.files) {
|
|
97
|
+
const to = PATH.join(target, file);
|
|
98
|
+
if (!force && FS.existsSync(to)) {
|
|
99
|
+
skipped.push(to);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
FS.mkdirSync(PATH.dirname(to), { recursive: true });
|
|
103
|
+
FS.writeFileSync(to, FS.readFileSync(PATH.join(found.source, file)));
|
|
104
|
+
written.push(to);
|
|
105
|
+
}
|
|
106
|
+
return { name: found.name, path: target, written, skipped };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Every file under `dir`, as `/`-separated paths relative to it.
|
|
110
|
+
function walk(dir: string, prefix = ''): string[] {
|
|
111
|
+
const out: string[] = [];
|
|
112
|
+
for (const entry of FS.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
113
|
+
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
114
|
+
if (entry.isDirectory()) out.push(...walk(PATH.join(dir, entry.name), relative));
|
|
115
|
+
else if (entry.isFile()) out.push(relative);
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// The `description:` line from a skill's YAML front matter — the one-liner
|
|
121
|
+
// Claude Code uses to decide whether a skill is relevant.
|
|
122
|
+
function describe(path: string): string {
|
|
123
|
+
try {
|
|
124
|
+
const text = FS.readFileSync(path, 'utf-8');
|
|
125
|
+
const front = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
|
|
126
|
+
if (!front) return '';
|
|
127
|
+
const line = /^description:[ \t]*(.*)$/m.exec(front[1]!);
|
|
128
|
+
return line ? line[1]!.trim().replace(/^["']|["']$/g, '') : '';
|
|
129
|
+
} catch {
|
|
130
|
+
return '';
|
|
131
|
+
}
|
|
132
|
+
}
|