@cejel/cejel 0.4.10 → 0.4.11

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/dist/index.js CHANGED
@@ -21,19 +21,452 @@ import {
21
21
  runCejelScan,
22
22
  serializeWitanReport,
23
23
  verifyWitanAttestationBinding
24
- } from "./chunk-TUUNJP27.js";
25
- import "./chunk-IHT7B6L7.js";
26
- import "./chunk-GKYTLWQK.js";
24
+ } from "./chunk-UBCIGOIP.js";
25
+ import "./chunk-63XQDYQ5.js";
26
+ import {
27
+ external_exports
28
+ } from "./chunk-FSMZV3Y6.js";
27
29
  import {
28
30
  init_esm_shims
29
- } from "./chunk-PTZU6G4E.js";
31
+ } from "./chunk-O3MHSK25.js";
30
32
 
31
33
  // src/index.ts
32
34
  init_esm_shims();
33
- import { createHash } from "crypto";
34
- import { mkdirSync, readFileSync, realpathSync, writeFileSync } from "fs";
35
- import { join, resolve } from "path";
36
- import { pathToFileURL } from "url";
35
+ import { createHash as createHash2 } from "crypto";
36
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "fs";
37
+ import { dirname, join, resolve } from "path";
38
+ import { fileURLToPath, pathToFileURL } from "url";
39
+
40
+ // src/issuance/index.ts
41
+ init_esm_shims();
42
+
43
+ // src/issuance/schemas.ts
44
+ init_esm_shims();
45
+ var WITAN_ISSUANCE_STATEMENT_TYPE = "https://in-toto.io/Statement/v1";
46
+ var WITAN_ISSUANCE_PREDICATE_TYPE = "https://cejel.dev/attestations/issuance/v1";
47
+ var WITAN_ISSUANCE_FORMAT_VERSION = "1.0";
48
+ var WITAN_ISSUANCE_SIGNATURE_NAMESPACE = "cejel-issuance";
49
+ var WITAN_ISSUANCE_PRINCIPAL = "issuance@barglabs.ai";
50
+ var WITAN_ISSUER_NAME = "Barg Labs Inc.";
51
+ var WitanIssuanceSubjectArtifactSchema = external_exports.enum(["report.json", "attestation.json"]);
52
+ var WitanIssuanceStatementSchema = external_exports.object({
53
+ _type: external_exports.literal(WITAN_ISSUANCE_STATEMENT_TYPE),
54
+ subject: external_exports.array(
55
+ external_exports.object({
56
+ name: external_exports.string().min(1).max(300),
57
+ artifact: WitanIssuanceSubjectArtifactSchema,
58
+ digest: external_exports.object({ sha256: external_exports.string().regex(/^[a-f0-9]{64}$/) }).strict()
59
+ }).strict()
60
+ ).length(2),
61
+ predicateType: external_exports.literal(WITAN_ISSUANCE_PREDICATE_TYPE),
62
+ predicate: external_exports.object({
63
+ issuanceFormatVersion: external_exports.string().regex(/^1\.\d+$/),
64
+ issuer: external_exports.object({
65
+ name: external_exports.literal(WITAN_ISSUER_NAME),
66
+ // OpenSSH public-key fingerprint of the signing key, as printed by `ssh-keygen -lf`.
67
+ keyFingerprint: external_exports.string().regex(/^SHA256:[A-Za-z0-9+/]{43}$/)
68
+ }).strict(),
69
+ reproduction: external_exports.object({
70
+ sourceRevision: external_exports.string().min(7).max(64),
71
+ toolPackage: external_exports.literal("@cejel/cejel"),
72
+ toolVersion: external_exports.string().min(1).max(80),
73
+ rubricVersion: external_exports.string().min(1).max(120),
74
+ rubricBehaviourFingerprint: external_exports.string().regex(/^sha256:[a-f0-9]{64}$/).optional(),
75
+ reportByteIdentical: external_exports.literal(true)
76
+ }).strict(),
77
+ issuedAt: external_exports.string().datetime({ offset: true }),
78
+ // An opaque identifier the issuer chooses. The charset is enforced here; that the value
79
+ // is genuinely opaque and names no counterparty is a policy rule this schema cannot
80
+ // check — see docs/issuance.md.
81
+ engagementRef: external_exports.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{3,63}$/),
82
+ limitations: external_exports.array(external_exports.string().min(1).max(500)).min(1).max(8)
83
+ }).strict()
84
+ }).strict();
85
+
86
+ // src/issuance/issuance.ts
87
+ init_esm_shims();
88
+ import { createHash, createPublicKey, verify as cryptoVerify } from "crypto";
89
+ var WITAN_ISSUANCE_LIMITATIONS = [
90
+ "Barg Labs Inc. attests only that it re-ran the named @cejel/cejel version at the named source revision and obtained byte-identical report.json content. It does not attest that the measurement itself is correct.",
91
+ "This is not a statement about the safety, security, correctness, or fitness for purpose of the software, and it carries no warranty or guarantee of any kind.",
92
+ "Nothing outside the pinned source tree was examined. Evidence absent from that tree was neither seen nor found to be absent.",
93
+ "Reproduction is not coverage. Cejel measures observable repository signals; a criterion it cannot measure is reported as unmeasured rather than scored, and this statement does not convert that into a pass."
94
+ ];
95
+ var WITAN_ISSUANCE_SUPPORTED_KEY_TYPES = ["ssh-ed25519"];
96
+ var SSHSIG_MAGIC = "SSHSIG";
97
+ var SSHSIG_VERSION = 1;
98
+ var SSHSIG_BEGIN = "-----BEGIN SSH SIGNATURE-----";
99
+ var SSHSIG_END = "-----END SSH SIGNATURE-----";
100
+ var SSHSIG_MAX_ARMORED_BYTES = 64 * 1024;
101
+ var SSHSIG_HASH_ALGORITHMS = /* @__PURE__ */ new Set(["sha256", "sha512"]);
102
+ var ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
103
+ var ED25519_PUBLIC_KEY_BYTES = 32;
104
+ var ED25519_SIGNATURE_BYTES = 64;
105
+ function serializeWitanIssuance(statement) {
106
+ return JSON.stringify(statement, null, 2);
107
+ }
108
+ function createWitanIssuanceStatement(report, options) {
109
+ const sourceRevision = report.repo.headSha;
110
+ if (!sourceRevision) {
111
+ throw new Error(
112
+ "Cejel: report.json records no repository revision (repo.headSha), so an issuance could not name what was reproduced."
113
+ );
114
+ }
115
+ return WitanIssuanceStatementSchema.parse({
116
+ _type: WITAN_ISSUANCE_STATEMENT_TYPE,
117
+ subject: [
118
+ {
119
+ name: `${report.productSlug}/report.json`,
120
+ artifact: "report.json",
121
+ digest: { sha256: options.reportSha256 }
122
+ },
123
+ {
124
+ name: `${report.productSlug}/attestation.json`,
125
+ artifact: "attestation.json",
126
+ digest: { sha256: options.attestationSha256 }
127
+ }
128
+ ],
129
+ predicateType: WITAN_ISSUANCE_PREDICATE_TYPE,
130
+ predicate: {
131
+ issuanceFormatVersion: WITAN_ISSUANCE_FORMAT_VERSION,
132
+ issuer: { name: WITAN_ISSUER_NAME, keyFingerprint: options.issuerKeyFingerprint },
133
+ reproduction: {
134
+ sourceRevision,
135
+ toolPackage: "@cejel/cejel",
136
+ toolVersion: options.toolVersion,
137
+ rubricVersion: report.rubricVersion,
138
+ ...report.rubricBehaviourFingerprint ? { rubricBehaviourFingerprint: report.rubricBehaviourFingerprint } : {},
139
+ reportByteIdentical: true
140
+ },
141
+ issuedAt: options.issuedAt,
142
+ engagementRef: options.engagementRef,
143
+ limitations: [...WITAN_ISSUANCE_LIMITATIONS]
144
+ }
145
+ });
146
+ }
147
+ function verifyWitanIssuanceBinding(statement, options) {
148
+ const parsed = WitanIssuanceStatementSchema.safeParse(statement);
149
+ if (!parsed.success) {
150
+ return {
151
+ valid: false,
152
+ errors: parsed.error.issues.map(
153
+ (issue) => `${issue.path.join(".") || "<root>"}: ${issue.message}`
154
+ )
155
+ };
156
+ }
157
+ const errors = [];
158
+ const reportSubject = parsed.data.subject.find((entry) => entry.artifact === "report.json");
159
+ const attestationSubject = parsed.data.subject.find(
160
+ (entry) => entry.artifact === "attestation.json"
161
+ );
162
+ if (!reportSubject) {
163
+ errors.push("issuance names no report.json subject");
164
+ } else {
165
+ if (reportSubject.digest.sha256 !== options.reportSha256) {
166
+ errors.push("issuance report.json subject digest does not match the supplied report.json");
167
+ }
168
+ if (reportSubject.name !== `${options.productSlug}/report.json`) {
169
+ errors.push("issuance report.json subject name does not match the report repository identity");
170
+ }
171
+ }
172
+ if (!attestationSubject) {
173
+ errors.push("issuance names no attestation.json subject");
174
+ } else {
175
+ if (attestationSubject.digest.sha256 !== options.attestationSha256) {
176
+ errors.push(
177
+ "issuance attestation.json subject digest does not match the supplied attestation.json"
178
+ );
179
+ }
180
+ if (attestationSubject.name !== `${options.productSlug}/attestation.json`) {
181
+ errors.push(
182
+ "issuance attestation.json subject name does not match the report repository identity"
183
+ );
184
+ }
185
+ }
186
+ if (parsed.data.predicate.reproduction.reportByteIdentical !== true) {
187
+ errors.push("issuance does not assert reportByteIdentical: true");
188
+ }
189
+ return { valid: errors.length === 0, errors };
190
+ }
191
+ var KEY_TYPE_PATTERN = /^(?:ssh-[a-z0-9]+|ecdsa-sha2-[a-z0-9-]+|sk-[a-z0-9-]+@openssh\.com|[a-z0-9-]+@openssh\.com)$/;
192
+ function splitRespectingQuotes(line) {
193
+ const tokens = [];
194
+ let current = "";
195
+ let quoted = false;
196
+ for (const character of line) {
197
+ if (character === '"') {
198
+ quoted = !quoted;
199
+ current += character;
200
+ continue;
201
+ }
202
+ if (!quoted && /\s/.test(character)) {
203
+ if (current.length > 0) {
204
+ tokens.push(current);
205
+ current = "";
206
+ }
207
+ continue;
208
+ }
209
+ current += character;
210
+ }
211
+ if (quoted) throw new Error("unterminated quoted field");
212
+ if (current.length > 0) tokens.push(current);
213
+ return tokens;
214
+ }
215
+ function parseAllowedSigners(text) {
216
+ const entries = [];
217
+ const lines = text.split("\n");
218
+ for (const [index, rawLine] of lines.entries()) {
219
+ const line = rawLine.replace(/\r$/, "").trim();
220
+ if (line.length === 0 || line.startsWith("#")) continue;
221
+ let tokens;
222
+ try {
223
+ tokens = splitRespectingQuotes(line);
224
+ } catch (error) {
225
+ const message = error instanceof Error ? error.message : String(error);
226
+ throw new Error(`allowed-signers line ${index + 1}: ${message}`);
227
+ }
228
+ const [principalsToken, ...rest] = tokens;
229
+ if (!principalsToken) continue;
230
+ const keyTypeIndex = rest.findIndex((token) => KEY_TYPE_PATTERN.test(token));
231
+ if (keyTypeIndex === -1) {
232
+ throw new Error(`allowed-signers line ${index + 1}: no recognised key type`);
233
+ }
234
+ const keyType = rest[keyTypeIndex];
235
+ const keyBase64 = rest[keyTypeIndex + 1];
236
+ if (!keyType || !keyBase64) {
237
+ throw new Error(`allowed-signers line ${index + 1}: key type is not followed by a key`);
238
+ }
239
+ const optionText = rest.slice(0, keyTypeIndex).join(" ");
240
+ const entry = {
241
+ principals: principalsToken.split(",").map((principal) => principal.replace(/^"|"$/g, "").trim()).filter((principal) => principal.length > 0),
242
+ keyType,
243
+ keyBase64
244
+ };
245
+ if (optionText.length > 0) {
246
+ const namespacesMatch = /^namespaces="([^"]*)"$/.exec(optionText);
247
+ if (namespacesMatch?.[1] !== void 0) {
248
+ entry.namespaces = namespacesMatch[1].split(",").map((namespace) => namespace.trim()).filter((namespace) => namespace.length > 0);
249
+ } else {
250
+ entry.uninterpretedOptions = optionText;
251
+ }
252
+ }
253
+ entries.push(entry);
254
+ }
255
+ return entries;
256
+ }
257
+ function sshPublicKeyFingerprint(keyBlob) {
258
+ return `SHA256:${createHash("sha256").update(keyBlob).digest("base64").replace(/=+$/, "")}`;
259
+ }
260
+ var SshWireReader = class {
261
+ constructor(buffer) {
262
+ this.buffer = buffer;
263
+ }
264
+ buffer;
265
+ offset = 0;
266
+ readBytes(length) {
267
+ if (length < 0 || this.offset + length > this.buffer.length) {
268
+ throw new Error("truncated SSH wire encoding");
269
+ }
270
+ const slice = this.buffer.subarray(this.offset, this.offset + length);
271
+ this.offset += length;
272
+ return slice;
273
+ }
274
+ readUint32() {
275
+ return this.readBytes(4).readUInt32BE(0);
276
+ }
277
+ readString() {
278
+ return this.readBytes(this.readUint32());
279
+ }
280
+ get remaining() {
281
+ return this.buffer.length - this.offset;
282
+ }
283
+ };
284
+ function sshString(value) {
285
+ const payload = typeof value === "string" ? Buffer.from(value, "utf8") : value;
286
+ const header = Buffer.alloc(4);
287
+ header.writeUInt32BE(payload.length, 0);
288
+ return Buffer.concat([header, payload]);
289
+ }
290
+ function parseSshSignature(armored) {
291
+ if (armored.length > SSHSIG_MAX_ARMORED_BYTES) {
292
+ throw new Error("signature file is implausibly large for an SSH signature");
293
+ }
294
+ const lines = armored.split("\n").map((line) => line.replace(/\r$/, "").trim());
295
+ const begin = lines.indexOf(SSHSIG_BEGIN);
296
+ const end = lines.indexOf(SSHSIG_END);
297
+ if (begin === -1 || end === -1 || end <= begin) {
298
+ throw new Error("not an armored SSH signature (missing BEGIN/END SSH SIGNATURE lines)");
299
+ }
300
+ const body = lines.slice(begin + 1, end).join("");
301
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(body)) {
302
+ throw new Error("armored SSH signature body is not base64");
303
+ }
304
+ const blob = Buffer.from(body, "base64");
305
+ const reader = new SshWireReader(blob);
306
+ const magic = reader.readBytes(SSHSIG_MAGIC.length).toString("utf8");
307
+ if (magic !== SSHSIG_MAGIC) throw new Error("signature is missing the SSHSIG preamble");
308
+ const version = reader.readUint32();
309
+ if (version !== SSHSIG_VERSION) {
310
+ throw new Error(`unsupported SSHSIG version ${version}`);
311
+ }
312
+ const publicKeyBlob = reader.readString();
313
+ const namespace = reader.readString().toString("utf8");
314
+ const reserved = reader.readString();
315
+ if (reserved.length !== 0) throw new Error("SSHSIG reserved field is not empty");
316
+ const hashAlgorithm = reader.readString().toString("utf8");
317
+ const signatureBlob = reader.readString();
318
+ if (reader.remaining !== 0) throw new Error("trailing bytes after the SSHSIG structure");
319
+ const keyType = new SshWireReader(publicKeyBlob).readString().toString("utf8");
320
+ return { publicKeyBlob, keyType, namespace, hashAlgorithm, signatureBlob };
321
+ }
322
+ function ed25519PublicKeyFromBlob(publicKeyBlob) {
323
+ const reader = new SshWireReader(publicKeyBlob);
324
+ const keyType = reader.readString().toString("utf8");
325
+ if (keyType !== "ssh-ed25519") throw new Error(`expected ssh-ed25519, found ${keyType}`);
326
+ const rawKey = reader.readString();
327
+ if (rawKey.length !== ED25519_PUBLIC_KEY_BYTES) {
328
+ throw new Error("ssh-ed25519 public key is not 32 bytes");
329
+ }
330
+ if (reader.remaining !== 0) throw new Error("trailing bytes after the ssh-ed25519 public key");
331
+ return createPublicKey({
332
+ key: Buffer.concat([ED25519_SPKI_PREFIX, rawKey]),
333
+ format: "der",
334
+ type: "spki"
335
+ });
336
+ }
337
+ function sshsigSignedData(namespace, hashAlgorithm, messageHash) {
338
+ return Buffer.concat([
339
+ Buffer.from(SSHSIG_MAGIC, "utf8"),
340
+ sshString(namespace),
341
+ sshString(""),
342
+ sshString(hashAlgorithm),
343
+ sshString(messageHash)
344
+ ]);
345
+ }
346
+ function verifyWitanIssuanceSignature(options) {
347
+ const expectedNamespace = options.namespace ?? WITAN_ISSUANCE_SIGNATURE_NAMESPACE;
348
+ const expectedPrincipal = options.principal ?? WITAN_ISSUANCE_PRINCIPAL;
349
+ let parsed;
350
+ try {
351
+ parsed = parseSshSignature(options.armoredSignature);
352
+ } catch (error) {
353
+ return { status: "invalid", reason: error instanceof Error ? error.message : String(error) };
354
+ }
355
+ if (parsed.namespace !== expectedNamespace) {
356
+ return {
357
+ status: "invalid",
358
+ reason: `signature namespace is ${JSON.stringify(parsed.namespace)}, expected ${JSON.stringify(expectedNamespace)}`
359
+ };
360
+ }
361
+ if (!SSHSIG_HASH_ALGORITHMS.has(parsed.hashAlgorithm)) {
362
+ return {
363
+ status: "unsupported",
364
+ reason: `signature hash algorithm ${JSON.stringify(parsed.hashAlgorithm)} is not one this build verifies`
365
+ };
366
+ }
367
+ if (!WITAN_ISSUANCE_SUPPORTED_KEY_TYPES.includes(parsed.keyType)) {
368
+ return {
369
+ status: "unsupported",
370
+ reason: `key type ${JSON.stringify(parsed.keyType)} is not one this build verifies (supported: ${WITAN_ISSUANCE_SUPPORTED_KEY_TYPES.join(", ")}); verify with OpenSSH instead \u2014 see docs/issuance.md`
371
+ };
372
+ }
373
+ const fingerprint = sshPublicKeyFingerprint(parsed.publicKeyBlob);
374
+ let signatureVerified;
375
+ try {
376
+ const publicKey = ed25519PublicKeyFromBlob(parsed.publicKeyBlob);
377
+ const signatureReader = new SshWireReader(parsed.signatureBlob);
378
+ const signatureType = signatureReader.readString().toString("utf8");
379
+ if (signatureType !== parsed.keyType) {
380
+ return {
381
+ status: "invalid",
382
+ reason: `inner signature type ${JSON.stringify(signatureType)} does not match the signing key type ${JSON.stringify(parsed.keyType)}`
383
+ };
384
+ }
385
+ const rawSignature = signatureReader.readString();
386
+ if (rawSignature.length !== ED25519_SIGNATURE_BYTES) {
387
+ return { status: "invalid", reason: "ssh-ed25519 signature is not 64 bytes" };
388
+ }
389
+ if (signatureReader.remaining !== 0) {
390
+ return { status: "invalid", reason: "trailing bytes after the ssh-ed25519 signature" };
391
+ }
392
+ const messageHash = createHash(parsed.hashAlgorithm).update(options.message).digest();
393
+ const signedData = sshsigSignedData(parsed.namespace, parsed.hashAlgorithm, messageHash);
394
+ signatureVerified = cryptoVerify(null, signedData, publicKey, rawSignature);
395
+ } catch (error) {
396
+ return { status: "invalid", reason: error instanceof Error ? error.message : String(error) };
397
+ }
398
+ if (!signatureVerified) {
399
+ return { status: "invalid", reason: "the signature does not verify over these issuance bytes" };
400
+ }
401
+ let signers;
402
+ try {
403
+ signers = parseAllowedSigners(options.allowedSignersText);
404
+ } catch (error) {
405
+ return { status: "invalid", reason: error instanceof Error ? error.message : String(error) };
406
+ }
407
+ if (signers.length === 0) {
408
+ return { status: "unlisted_key", keyType: parsed.keyType, fingerprint };
409
+ }
410
+ const signedKeyBase64 = parsed.publicKeyBlob.toString("base64");
411
+ const listed = signers.some(
412
+ (entry) => entry.uninterpretedOptions === void 0 && entry.keyType === parsed.keyType && normalizeBase64(entry.keyBase64) === signedKeyBase64 && entry.principals.includes(expectedPrincipal) && (entry.namespaces === void 0 || entry.namespaces.includes(expectedNamespace))
413
+ );
414
+ if (!listed) {
415
+ return { status: "unlisted_key", keyType: parsed.keyType, fingerprint };
416
+ }
417
+ return { status: "valid", principal: expectedPrincipal, keyType: parsed.keyType, fingerprint };
418
+ }
419
+ function normalizeBase64(value) {
420
+ return Buffer.from(value, "base64").toString("base64");
421
+ }
422
+ var REVOCATION_LINE_PATTERN = /^([a-f0-9]{64})\s+(\d{4}-\d{2}-\d{2})\s+(\S.*)$/;
423
+ function parseIssuerRevocations(text) {
424
+ const revocations = [];
425
+ for (const [index, rawLine] of text.split("\n").entries()) {
426
+ const line = rawLine.replace(/\r$/, "").trim();
427
+ if (line.length === 0 || line.startsWith("#")) continue;
428
+ const match = REVOCATION_LINE_PATTERN.exec(line);
429
+ if (!match?.[1] || !match[2] || !match[3]) {
430
+ throw new Error(
431
+ `issuer-revocations line ${index + 1} is not "<report-sha256> <YYYY-MM-DD> <reason>"`
432
+ );
433
+ }
434
+ revocations.push({ reportSha256: match[1], date: match[2], reason: match[3].trim() });
435
+ }
436
+ return revocations;
437
+ }
438
+ function findIssuerRevocation(revocations, reportSha256) {
439
+ return revocations.find((revocation) => revocation.reportSha256 === reportSha256);
440
+ }
441
+ var PRIVATE_KEY_MARKER = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/;
442
+ function parseIssuerPublicKey(contents) {
443
+ if (PRIVATE_KEY_MARKER.test(contents)) {
444
+ throw new Error(
445
+ "Cejel: --key names a file containing PRIVATE key material. Cejel never reads a private key and never signs: the issuer key is generated on a hardware token, exists as no file, and signs through ssh-agent. Pass the PUBLIC key (the .pub file) instead."
446
+ );
447
+ }
448
+ const line = contents.split("\n").map((entry) => entry.trim()).find((entry) => entry.length > 0 && !entry.startsWith("#"));
449
+ if (!line) throw new Error("Cejel: --key file contains no public key line.");
450
+ const tokens = line.split(/\s+/);
451
+ const [keyType, keyBase64] = tokens;
452
+ if (!keyType || !keyBase64 || !KEY_TYPE_PATTERN.test(keyType)) {
453
+ throw new Error(
454
+ 'Cejel: --key file is not an OpenSSH public key ("<keytype> <base64> [comment]").'
455
+ );
456
+ }
457
+ let blob;
458
+ try {
459
+ blob = Buffer.from(keyBase64, "base64");
460
+ const declaredType = new SshWireReader(blob).readString().toString("utf8");
461
+ if (declaredType !== keyType) {
462
+ throw new Error(`key blob declares ${declaredType} but the line says ${keyType}`);
463
+ }
464
+ } catch (error) {
465
+ const message = error instanceof Error ? error.message : String(error);
466
+ throw new Error(`Cejel: --key file does not hold a decodable OpenSSH public key: ${message}`);
467
+ }
468
+ return { keyType, keyBase64, fingerprint: sshPublicKeyFingerprint(blob) };
469
+ }
37
470
 
38
471
  // src/terminal.ts
39
472
  init_esm_shims();
@@ -178,6 +611,21 @@ function formatScore(score) {
178
611
 
179
612
  // src/index.ts
180
613
  var DEFAULT_OUT_DIR = ".cejel";
614
+ var ISSUER_SIGNERS_FILE = "issuer-signers";
615
+ var ISSUER_REVOCATIONS_FILE = "issuer-revocations";
616
+ var SECURITY_FILE_CANDIDATES = ["../docs/security/", "../../docs/security/"];
617
+ function resolveBundledSecurityFile(name) {
618
+ for (const prefix of SECURITY_FILE_CANDIDATES) {
619
+ let candidate;
620
+ try {
621
+ candidate = fileURLToPath(new URL(`${prefix}${name}`, import.meta.url));
622
+ } catch {
623
+ continue;
624
+ }
625
+ if (existsSync(candidate)) return candidate;
626
+ }
627
+ return void 0;
628
+ }
181
629
  var CLI_FLAG_SPECS = [
182
630
  {
183
631
  tokens: ["--out", "--out-dir"],
@@ -281,15 +729,36 @@ var USAGE = `cejel \u2014 a trust certificate for your codebase
281
729
  Usage:
282
730
  npx ${NPX_PACKAGE_NAME} [path] [options]
283
731
  npx ${NPX_PACKAGE_NAME} scan [path] [options]
284
- npx ${NPX_PACKAGE_NAME} verify <report.json> <attestation.json>
732
+ npx ${NPX_PACKAGE_NAME} verify <report.json> <attestation.json> [issuance.json issuance.json.sig]
733
+ npx ${NPX_PACKAGE_NAME} issue [path] --report <report.json> --attestation <attestation.json> --key <key.pub> --engagement-ref <ref>
285
734
 
286
735
  Commands:
287
736
  scan score a repository (default when the command is omitted)
288
- verify verify report/attestation binding only; no signature or signer identity check
737
+ verify verify the report/attestation binding; with an issuance pair, also check the issuer
738
+ signature against docs/security/${ISSUER_SIGNERS_FILE} and docs/security/${ISSUER_REVOCATIONS_FILE}
739
+ issue re-run this version at the certificate's revision and, only if report.json reproduces
740
+ byte for byte, write an unsigned issuance.json for the issuer to sign through ssh-agent
289
741
 
290
742
  Scan options:
291
743
  ${usageOptions}
292
744
 
745
+ Verify options:
746
+ --signers <file> use this allowed-signers file instead of the published one
747
+ --revocations <file> use this revocations file instead of the published one
748
+
749
+ Issue options:
750
+ --report <file> the report.json being countersigned (required)
751
+ --attestation <file> the attestation.json beside it (required)
752
+ --key <file> the issuer PUBLIC key (.pub); a private key file is refused (required)
753
+ --engagement-ref <id> opaque issuer-chosen identifier; never a counterparty name (required)
754
+ --out <dir> where issuance.json is written (default: the report.json directory)
755
+ --product-name <name> pass exactly what the original scan used, or the bytes will not reproduce
756
+ --name <display> pass exactly what the original scan used, or the bytes will not reproduce
757
+ --rubric-pin <version> pass exactly what the original scan used, or the bytes will not reproduce
758
+ --ingest <file> pass exactly what the original scan used, or the bytes will not reproduce
759
+
760
+ cejel never signs and never reads private key material.
761
+
293
762
  Runs entirely offline. No code leaves your machine.
294
763
  Docs: https://cejel.dev
295
764
  `;
@@ -306,7 +775,10 @@ async function runWitanV22CalibrationCli(args) {
306
775
  async function runWitanCli(args, rubricVersion) {
307
776
  const invocation = parseCliInvocation(args);
308
777
  if (invocation.command === "verify") {
309
- return runVerifyBinding(invocation.reportPath, invocation.attestationPath);
778
+ return runVerifyBinding(invocation);
779
+ }
780
+ if (invocation.command === "issue") {
781
+ return runIssue(invocation.options);
310
782
  }
311
783
  const options = invocation.options;
312
784
  if (options.showVersion) {
@@ -402,23 +874,148 @@ function parseCliInvocation(args) {
402
874
  if (verifyArgs.some((arg) => arg === "-v" || arg === "--version")) {
403
875
  return { command: "scan", options: parseArgs(["--version"]) };
404
876
  }
405
- if (verifyArgs.length !== 2) {
406
- throw new Error(`Usage: npx ${NPX_PACKAGE_NAME} verify <report.json> <attestation.json>`);
877
+ return parseVerifyArgs(verifyArgs);
878
+ }
879
+ if (command === "issue") {
880
+ const issueArgs = args.slice(1);
881
+ if (issueArgs.some((arg) => arg === "-h" || arg === "--help")) {
882
+ return { command: "scan", options: parseArgs(["--help"]) };
407
883
  }
408
- const [reportPath, attestationPath] = verifyArgs;
409
- if (!reportPath || !attestationPath) {
410
- throw new Error(`Usage: npx ${NPX_PACKAGE_NAME} verify <report.json> <attestation.json>`);
884
+ if (issueArgs.some((arg) => arg === "-v" || arg === "--version")) {
885
+ return { command: "scan", options: parseArgs(["--version"]) };
411
886
  }
412
- return {
413
- command: "verify",
414
- reportPath: resolve(reportPath),
415
- attestationPath: resolve(attestationPath)
416
- };
887
+ return { command: "issue", options: parseIssueArgs(issueArgs) };
417
888
  }
418
889
  return { command: "scan", options: parseArgs(args) };
419
890
  }
420
- function runVerifyBinding(reportPath, attestationPath) {
421
- const reportArtifact = readJsonArtifact(reportPath, "report");
891
+ var VERIFY_USAGE = () => `Usage: npx ${NPX_PACKAGE_NAME} verify <report.json> <attestation.json> [issuance.json issuance.json.sig] [--signers <file>] [--revocations <file>]`;
892
+ function parseVerifyArgs(args) {
893
+ const positionals = [];
894
+ let signersPath;
895
+ let revocationsPath;
896
+ for (let index = 0; index < args.length; index += 1) {
897
+ const arg = args[index];
898
+ if (arg === void 0) continue;
899
+ if (arg === "--signers" || arg === "--revocations") {
900
+ const value = args[index + 1];
901
+ if (!value) throw new Error(`Missing value for ${arg}`);
902
+ if (arg === "--signers") signersPath = resolve(value);
903
+ else revocationsPath = resolve(value);
904
+ index += 1;
905
+ continue;
906
+ }
907
+ if (arg.startsWith("-")) throw new Error(`Unknown Cejel verify flag: ${arg}`);
908
+ positionals.push(arg);
909
+ }
910
+ if (positionals.length !== 2 && positionals.length !== 4) throw new Error(VERIFY_USAGE());
911
+ const [reportPath, attestationPath, issuancePath, issuanceSignaturePath] = positionals;
912
+ if (!reportPath || !attestationPath) throw new Error(VERIFY_USAGE());
913
+ return {
914
+ command: "verify",
915
+ reportPath: resolve(reportPath),
916
+ attestationPath: resolve(attestationPath),
917
+ ...issuancePath ? { issuancePath: resolve(issuancePath) } : {},
918
+ ...issuanceSignaturePath ? { issuanceSignaturePath: resolve(issuanceSignaturePath) } : {},
919
+ ...signersPath ? { signersPath } : {},
920
+ ...revocationsPath ? { revocationsPath } : {}
921
+ };
922
+ }
923
+ function parseIssueArgs(args) {
924
+ let repoPath;
925
+ let reportPath;
926
+ let attestationPath;
927
+ let keyPath;
928
+ let engagementRef;
929
+ let outDir;
930
+ let productName;
931
+ let productDisplayName;
932
+ let rubricPin;
933
+ const ingestPatterns = [];
934
+ function takeValue(arg, index) {
935
+ const value = args[index];
936
+ if (!value) throw new Error(`Missing value for ${arg}`);
937
+ return value;
938
+ }
939
+ for (let index = 0; index < args.length; index += 1) {
940
+ const arg = args[index];
941
+ if (arg === void 0) continue;
942
+ switch (arg) {
943
+ case "--report":
944
+ reportPath = resolve(takeValue(arg, index + 1));
945
+ index += 1;
946
+ continue;
947
+ case "--attestation":
948
+ attestationPath = resolve(takeValue(arg, index + 1));
949
+ index += 1;
950
+ continue;
951
+ case "--key":
952
+ keyPath = resolve(takeValue(arg, index + 1));
953
+ index += 1;
954
+ continue;
955
+ case "--engagement-ref":
956
+ engagementRef = takeValue(arg, index + 1).trim();
957
+ index += 1;
958
+ continue;
959
+ case "--out":
960
+ case "--out-dir":
961
+ outDir = resolve(takeValue(arg, index + 1));
962
+ index += 1;
963
+ continue;
964
+ case "--product-name":
965
+ productName = takeValue(arg, index + 1).trim();
966
+ index += 1;
967
+ continue;
968
+ case "--name":
969
+ productDisplayName = takeValue(arg, index + 1).trim();
970
+ index += 1;
971
+ continue;
972
+ case "--rubric-pin": {
973
+ const value = takeValue(arg, index + 1);
974
+ assertSelectableRubricVersion(value);
975
+ rubricPin = value;
976
+ index += 1;
977
+ continue;
978
+ }
979
+ case "--ingest":
980
+ ingestPatterns.push(takeValue(arg, index + 1));
981
+ index += 1;
982
+ continue;
983
+ default:
984
+ break;
985
+ }
986
+ if (arg.startsWith("-")) throw new Error(`Unknown Cejel issue flag: ${arg}`);
987
+ if (repoPath !== void 0) throw new Error(`Unexpected positional argument: ${arg}`);
988
+ repoPath = arg;
989
+ }
990
+ const missing = [
991
+ reportPath ? null : "--report",
992
+ attestationPath ? null : "--attestation",
993
+ keyPath ? null : "--key",
994
+ engagementRef ? null : "--engagement-ref"
995
+ ].filter((entry) => entry !== null);
996
+ if (missing.length > 0 || !reportPath || !attestationPath || !keyPath || !engagementRef) {
997
+ throw new Error(
998
+ `Cejel issue requires ${missing.join(", ")}. Usage: npx ${NPX_PACKAGE_NAME} issue [path] --report <report.json> --attestation <attestation.json> --key <key.pub> --engagement-ref <ref>`
999
+ );
1000
+ }
1001
+ if (productName && productDisplayName) {
1002
+ throw new Error("--product-name and --name cannot be used together");
1003
+ }
1004
+ return {
1005
+ repoPath: resolve(repoPath ?? "."),
1006
+ reportPath,
1007
+ attestationPath,
1008
+ keyPath,
1009
+ engagementRef,
1010
+ outDir: outDir ?? dirname(reportPath),
1011
+ ...productName ? { productName } : {},
1012
+ ...productDisplayName ? { productDisplayName } : {},
1013
+ ...rubricPin ? { rubricPin } : {},
1014
+ ingestPatterns
1015
+ };
1016
+ }
1017
+ function runVerifyBinding(invocation) {
1018
+ const reportArtifact = readJsonArtifact(invocation.reportPath, "report");
422
1019
  const reportResult = WitanReportSchema.safeParse(reportArtifact.value);
423
1020
  if (!reportResult.success) {
424
1021
  const members = reportResult.error.issues.map(
@@ -431,8 +1028,8 @@ ${members.map((member) => ` - ${member}`).join("\n")}
431
1028
  );
432
1029
  return 1;
433
1030
  }
434
- const statement = readJsonArtifact(attestationPath, "attestation");
435
- const reportSha256 = createHash("sha256").update(reportArtifact.contents).digest("hex");
1031
+ const statement = readJsonArtifact(invocation.attestationPath, "attestation");
1032
+ const reportSha256 = createHash2("sha256").update(reportArtifact.contents).digest("hex");
436
1033
  const result = verifyWitanAttestationBinding(statement.value, reportResult.data, {
437
1034
  reportSha256
438
1035
  });
@@ -445,7 +1042,190 @@ ${result.errors.map((error) => ` - ${error}`).join("\n")}
445
1042
  return 1;
446
1043
  }
447
1044
  process.stdout.write("Cejel: report/attestation binding verified.\n");
448
- process.stdout.write("Cejel: signature and signer identity were not verified.\n");
1045
+ if (!invocation.issuancePath || !invocation.issuanceSignaturePath) {
1046
+ process.stdout.write("Cejel: signature and signer identity were not verified.\n");
1047
+ return 0;
1048
+ }
1049
+ const attestationSha256 = createHash2("sha256").update(statement.contents).digest("hex");
1050
+ return runVerifyIssuance(invocation, {
1051
+ reportSha256,
1052
+ attestationSha256,
1053
+ productSlug: reportResult.data.productSlug,
1054
+ issuancePath: invocation.issuancePath,
1055
+ issuanceSignaturePath: invocation.issuanceSignaturePath
1056
+ });
1057
+ }
1058
+ function runVerifyIssuance(invocation, context) {
1059
+ const issuance = readJsonArtifact(context.issuancePath, "issuance");
1060
+ let armoredSignature;
1061
+ try {
1062
+ armoredSignature = readFileSync(context.issuanceSignaturePath, "utf8");
1063
+ } catch (error) {
1064
+ const message = error instanceof Error ? error.message : String(error);
1065
+ throw new Error(
1066
+ `Cejel: could not read issuance signature file ${context.issuanceSignaturePath}: ${message}`
1067
+ );
1068
+ }
1069
+ const signersPath = invocation.signersPath ?? resolveBundledSecurityFile(ISSUER_SIGNERS_FILE);
1070
+ const revocationsPath = invocation.revocationsPath ?? resolveBundledSecurityFile(ISSUER_REVOCATIONS_FILE);
1071
+ let signatureLine;
1072
+ let signatureOk = false;
1073
+ if (!signersPath) {
1074
+ signatureLine = `signature: NOT VERIFIED \u2014 no ${ISSUER_SIGNERS_FILE} file was found beside this build; pass --signers <file>`;
1075
+ } else {
1076
+ const verdict = verifyWitanIssuanceSignature({
1077
+ message: issuance.contents,
1078
+ armoredSignature,
1079
+ allowedSignersText: readFileSync(signersPath, "utf8")
1080
+ });
1081
+ switch (verdict.status) {
1082
+ case "valid":
1083
+ signatureOk = true;
1084
+ signatureLine = `signature: valid \u2014 signed by ${verdict.principal} (${verdict.keyType}, ${verdict.fingerprint}) under namespace ${WITAN_ISSUANCE_SIGNATURE_NAMESPACE}`;
1085
+ break;
1086
+ case "unlisted_key":
1087
+ signatureLine = `signature: SIGNED BY AN UNLISTED KEY \u2014 ${verdict.keyType} ${verdict.fingerprint} is not listed for ${WITAN_ISSUANCE_PRINCIPAL} in ${signersPath}`;
1088
+ break;
1089
+ case "unsupported":
1090
+ signatureLine = `signature: NOT VERIFIED \u2014 ${verdict.reason}`;
1091
+ break;
1092
+ default:
1093
+ signatureLine = `signature: INVALID \u2014 ${verdict.reason}`;
1094
+ break;
1095
+ }
1096
+ }
1097
+ const binding = verifyWitanIssuanceBinding(issuance.value, {
1098
+ reportSha256: context.reportSha256,
1099
+ attestationSha256: context.attestationSha256,
1100
+ productSlug: context.productSlug
1101
+ });
1102
+ const bindingLine = binding.valid ? "binding: valid \u2014 the issuance names the exact report.json and attestation.json supplied, and asserts reportByteIdentical: true" : `binding: INVALID \u2014 ${binding.errors.join("; ")}`;
1103
+ let revocationLine;
1104
+ let revocationOk = false;
1105
+ if (!revocationsPath) {
1106
+ revocationLine = `revocation: NOT CHECKED \u2014 no ${ISSUER_REVOCATIONS_FILE} file was found beside this build; pass --revocations <file>`;
1107
+ } else {
1108
+ let revocation;
1109
+ try {
1110
+ revocation = findIssuerRevocation(
1111
+ parseIssuerRevocations(readFileSync(revocationsPath, "utf8")),
1112
+ context.reportSha256
1113
+ );
1114
+ } catch (error) {
1115
+ const message = error instanceof Error ? error.message : String(error);
1116
+ process.stdout.write(`${signatureLine}
1117
+ ${bindingLine}
1118
+ `);
1119
+ process.stdout.write(`revocation: NOT CHECKED \u2014 ${message}
1120
+ `);
1121
+ return 1;
1122
+ }
1123
+ if (revocation) {
1124
+ revocationLine = `revocation: REVOKED \u2014 withdrawn ${revocation.date}: ${revocation.reason}`;
1125
+ } else {
1126
+ revocationOk = true;
1127
+ revocationLine = `revocation: not revoked \u2014 this report digest is absent from ${revocationsPath}`;
1128
+ }
1129
+ }
1130
+ process.stdout.write(`${signatureLine}
1131
+ ${bindingLine}
1132
+ ${revocationLine}
1133
+ `);
1134
+ return signatureOk && binding.valid && revocationOk ? 0 : 1;
1135
+ }
1136
+ function runIssue(options) {
1137
+ const reportArtifact = readJsonArtifact(options.reportPath, "report");
1138
+ const reportResult = WitanReportSchema.safeParse(reportArtifact.value);
1139
+ if (!reportResult.success) {
1140
+ const members = reportResult.error.issues.map(
1141
+ (issue) => `${issue.path.join(".") || "<root>"}: ${issue.message}`
1142
+ );
1143
+ process.stderr.write(
1144
+ `Cejel: report validation failed:
1145
+ ${members.map((member) => ` - ${member}`).join("\n")}
1146
+ `
1147
+ );
1148
+ return 1;
1149
+ }
1150
+ const suppliedReport = reportResult.data;
1151
+ const attestationArtifact = readJsonArtifact(options.attestationPath, "attestation");
1152
+ const reportSha256 = createHash2("sha256").update(reportArtifact.contents).digest("hex");
1153
+ const attestationSha256 = createHash2("sha256").update(attestationArtifact.contents).digest("hex");
1154
+ const attestationBinding = verifyWitanAttestationBinding(
1155
+ attestationArtifact.value,
1156
+ suppliedReport,
1157
+ { reportSha256 }
1158
+ );
1159
+ if (!attestationBinding.valid) {
1160
+ process.stderr.write(
1161
+ `Cejel: refusing to issue \u2014 the supplied report and attestation are not bound to each other:
1162
+ ${attestationBinding.errors.map((error) => ` - ${error}`).join("\n")}
1163
+ `
1164
+ );
1165
+ return 1;
1166
+ }
1167
+ let keyContents;
1168
+ try {
1169
+ keyContents = readFileSync(options.keyPath, "utf8");
1170
+ } catch (error) {
1171
+ const message = error instanceof Error ? error.message : String(error);
1172
+ throw new Error(`Cejel: could not read --key file ${options.keyPath}: ${message}`);
1173
+ }
1174
+ const issuerKey = parseIssuerPublicKey(keyContents);
1175
+ const reproduction = runCejelScan({
1176
+ repoPath: options.repoPath,
1177
+ ...options.productName ? { productName: options.productName } : {},
1178
+ ...options.productDisplayName ? { productDisplayName: options.productDisplayName } : {},
1179
+ ...options.rubricPin ? { rubricVersion: options.rubricPin } : {},
1180
+ ingestPatterns: options.ingestPatterns,
1181
+ warnOnEmptyIngestMatch: true,
1182
+ toolVersion: cliVersion()
1183
+ });
1184
+ const reproducedBytes = serializeWitanReport(reproduction.report);
1185
+ const reproducedSha256 = createHash2("sha256").update(reproducedBytes, "utf8").digest("hex");
1186
+ if (reproducedSha256 !== reportSha256) {
1187
+ process.stderr.write(
1188
+ [
1189
+ "Cejel: refusing to issue \u2014 report.json did not reproduce byte for byte.",
1190
+ ` supplied report.json: sha256:${reportSha256}`,
1191
+ ` re-run at ${options.repoPath}: sha256:${reproducedSha256}`,
1192
+ ` this build: @cejel/cejel ${cliVersion()}`,
1193
+ " Check that the checkout is at the certificate revision, that the @cejel/cejel version matches",
1194
+ " report.json's toolVersion, and that --product-name/--name/--rubric-pin/--ingest match the",
1195
+ " original invocation. An issuance that cannot reproduce is not issued.",
1196
+ ""
1197
+ ].join("\n")
1198
+ );
1199
+ return 1;
1200
+ }
1201
+ const statement = createWitanIssuanceStatement(suppliedReport, {
1202
+ reportSha256,
1203
+ attestationSha256,
1204
+ issuerKeyFingerprint: issuerKey.fingerprint,
1205
+ toolVersion: cliVersion(),
1206
+ issuedAt: (/* @__PURE__ */ new Date()).toISOString(),
1207
+ engagementRef: options.engagementRef
1208
+ });
1209
+ mkdirSync(options.outDir, { recursive: true });
1210
+ const issuancePath = join(options.outDir, "issuance.json");
1211
+ writeFileSync(issuancePath, serializeWitanIssuance(statement), "utf8");
1212
+ process.stdout.write(
1213
+ [
1214
+ `Cejel: reproduced report.json byte for byte (sha256:${reportSha256}).`,
1215
+ `Wrote:
1216
+ ${issuancePath}`,
1217
+ "",
1218
+ "report.json and attestation.json were not modified. Cejel does not sign and never reads a",
1219
+ "private key. Sign through ssh-agent, with the issuer key on its token:",
1220
+ "",
1221
+ ` ssh-keygen -Y sign -n ${WITAN_ISSUANCE_SIGNATURE_NAMESPACE} -f ${options.keyPath} -U ${issuancePath}`,
1222
+ "",
1223
+ "Then verify before delivery:",
1224
+ "",
1225
+ ` npx ${NPX_PACKAGE_NAME} verify ${options.reportPath} ${options.attestationPath} ${issuancePath} ${issuancePath}.sig`,
1226
+ ""
1227
+ ].join("\n")
1228
+ );
449
1229
  return 0;
450
1230
  }
451
1231
  function readJsonArtifact(path, label) {
@@ -623,6 +1403,8 @@ export {
623
1403
  WitanReportSchema,
624
1404
  parseArgs,
625
1405
  parseCliInvocation,
1406
+ parseIssueArgs,
1407
+ parseVerifyArgs,
626
1408
  runWitanFreeCli,
627
1409
  runWitanV22CalibrationCli,
628
1410
  verifyWitanAttestationBinding