File size: 13,761 Bytes
518343a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
/**
 * rekor_submit.ts — Submit SZL DSSE envelopes to Sigstore Rekor v1 transparency log
 *
 * Implements the Rekor "hashedrekord" entry type (v0.0.1) per the Sigstore
 * transparency log API documented at https://docs.sigstore.dev/logging/overview/
 * and the OpenAPI spec at https://rekor.sigstore.dev/api/v1.
 *
 * Every DSSE receipt produced by the SZL organ layer (sentra/amaru/rosie) is
 * independently timestamped via Rekor's Merkle-tree signed tree head (STH).
 * The returned UUID (logIndex + treeID) is stored alongside the original JSONL
 * receipt so that any third party can reproduce the inclusion proof without
 * trusting SZL infrastructure.
 *
 * STAGED-ADVISORY: Production use requires a Fulcio OIDC token (GitHub Actions
 * OIDC or workload-identity token).  The `signingKey` path below accepts a
 * plain ECDSA P-256 key for local development; swap for
 * `@sigstore/sign` Fulcio flow for keyless CI.
 *
 * Refs:
 *   - Rekor OpenAPI:  https://rekor.sigstore.dev/api/v1
 *   - hashedrekord:   https://github.com/sigstore/rekor/tree/main/pkg/types/hashedrekord
 *   - DSSE spec:      https://github.com/secure-systems-lab/dsse/blob/master/protocol.md
 *   - RFC 8785 (JCS): https://www.rfc-editor.org/rfc/rfc8785
 */

import { createHash, createSign, createVerify, generateKeyPairSync } from "node:crypto";
import { readFileSync } from "node:fs";

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

/** Canonical DSSE envelope as produced by SZL organ layer (sentra/amaru/rosie) */
export interface DSSEEnvelope {
  payload: string;           // raw body, base64-encoded (NOT the PAE; per DSSE
                             // the PAE is computed at sign time from this body
                             // and is never stored here). See PhD_CRYPTO_VERDICT.md A3.
  payloadType: string;       // e.g. "application/vnd.szl.receipt.v1+json"
  signatures: Array<{
    sig: string;             // base64url HMAC-SHA256 or ECDSA signature
    keyid?: string;
  }>;
}

/** Rekor hashedrekord v0.0.1 body (base64-encoded JSON) */
export interface HashedRekordBody {
  apiVersion: "0.0.1";
  kind: "hashedrekord";
  spec: {
    data: {
      hash: {
        algorithm: "sha256";
        value: string;       // hex SHA-256 of the canonical payload bytes
      };
    };
    signature: {
      content: string;       // base64-encoded DER signature
      publicKey: {
        content: string;     // base64-encoded PEM public key
      };
    };
  };
}

/** Rekor log entry as returned by POST /api/v1/log/entries */
export interface RekorEntry {
  [uuid: string]: {
    body: string;            // base64-encoded HashedRekordBody JSON
    integratedTime: number;  // Unix epoch seconds
    logID: string;
    logIndex: number;
    verification: {
      inclusionProof: {
        checkpoint: string;
        hashes: string[];
        logIndex: number;
        rootHash: string;
        treeSize: number;
      };
      signedEntryTimestamp: string; // base64-encoded RFC 3161 SET
    };
  };
}

/** SZL-specific Rekor submission result, stored alongside DSSE receipt JSONL */
export interface RekorSubmitResult {
  uuid: string;
  logIndex: number;
  integratedTime: number;
  treeID: string;
  inclusionProofRootHash: string;
  rekorEntryUrl: string;
  payloadHash: string;
  submittedAt: string;       // ISO-8601
}

// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------

const REKOR_BASE = "https://rekor.sigstore.dev";
const REKOR_STAGING_BASE = "https://rekor.sigstore.dev"; // staging shares prod URL; use --staging flag in rekor-cli
const API_VERSION = "v1";

export interface RekorSubmitOptions {
  /** Use staging endpoint (same host, different tree in practice via CLI --staging) */
  staging?: boolean;
  /** Timeout in ms (default 10000) */
  timeoutMs?: number;
  /** PEM-encoded ECDSA P-256 private key for signing (dev mode).
   *  In production, leave undefined and provide `oidcToken` for Fulcio keyless. */
  signingKeyPem?: string;
  /** PEM-encoded public key matching signingKeyPem */
  publicKeyPem?: string;
  /** GitHub OIDC token for Fulcio keyless (STAGED-ADVISORY: wires into Fulcio CA) */
  oidcToken?: string;
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/**
 * Canonical payload bytes: UTF-8 encode the DSSE payload field (base64url → raw).
 * We hash the *decoded* payload so the hash is algorithm-agnostic and matches
 * what `rekor-cli` produces for the same artifact.
 */
function decodeBase64url(b64: string): Buffer {
  // base64url → base64 standard
  const std = b64.replace(/-/g, "+").replace(/_/g, "/");
  return Buffer.from(std, "base64");
}

/** SHA-256 hex digest of bytes */
function sha256Hex(data: Buffer | string): string {
  return createHash("sha256").update(data).digest("hex");
}

/**
 * Build a minimal self-signed ECDSA P-256 key pair for dev/test purposes.
 * In production this is replaced by a Fulcio-issued ephemeral cert chain.
 */
export function generateDevKeyPair(): { privateKeyPem: string; publicKeyPem: string } {
  const { privateKey, publicKey } = generateKeyPairSync("ec", {
    namedCurve: "P-256",
    publicKeyEncoding: { type: "spki", format: "pem" },
    privateKeyEncoding: { type: "pkcs8", format: "pem" },
  });
  return { privateKeyPem: privateKey as string, publicKeyPem: publicKey as string };
}

/**
 * Sign the canonical artifact bytes with ECDSA-P256-SHA256.
 * Returns a base64-encoded DER signature (not base64url) because the Rekor
 * hashedrekord spec uses standard base64 for the `signature.content` field.
 */
function signArtifact(artifactBytes: Buffer, privateKeyPem: string): string {
  const signer = createSign("SHA256");
  signer.update(artifactBytes);
  signer.end();
  return signer.sign(privateKeyPem, "base64");
}

/**
 * Encode a HashedRekordBody object to the base64 string that Rekor expects
 * as the `body` field in the POST request.
 */
function encodeBody(body: HashedRekordBody): string {
  return Buffer.from(JSON.stringify(body)).toString("base64");
}

// ---------------------------------------------------------------------------
// Core: submit
// ---------------------------------------------------------------------------

/**
 * Submit a DSSE envelope to the Sigstore Rekor public transparency log.
 *
 * Flow:
 *   1. Decode DSSE payload bytes
 *   2. Compute SHA-256 hash of payload bytes
 *   3. Sign payload bytes with ECDSA-P256 (dev key) or Fulcio (keyless)
 *   4. Build hashedrekord body
 *   5. POST to /api/v1/log/entries
 *   6. Extract UUID, logIndex, inclusionProof
 *   7. Return RekorSubmitResult for JSONL side-car
 *
 * @param envelope  - SZL DSSE receipt envelope
 * @param opts      - signing + endpoint options
 * @returns         - Rekor submission metadata
 */
export async function submitDSSEToRekor(
  envelope: DSSEEnvelope,
  opts: RekorSubmitOptions = {}
): Promise<RekorSubmitResult> {
  const base = opts.staging ? REKOR_STAGING_BASE : REKOR_BASE;
  const url = `${base}/api/${API_VERSION}/log/entries`;

  // 1. Decode payload bytes (DSSE payload field is base64url)
  const payloadBytes = decodeBase64url(envelope.payload);

  // 2. Hash
  const hashHex = sha256Hex(payloadBytes);

  // 3. Sign
  let sigB64: string;
  let pubKeyPem: string;

  if (opts.signingKeyPem && opts.publicKeyPem) {
    sigB64 = signArtifact(payloadBytes, opts.signingKeyPem);
    pubKeyPem = opts.publicKeyPem;
  } else {
    // STAGED-ADVISORY: Fulcio keyless path — requires real OIDC token
    // In a GitHub Actions environment this token is obtained via:
    //   const { getIDToken } = await import("@actions/core");
    //   const oidcToken = await getIDToken("sigstore");
    // Then exchanged via https://fulcio.sigstore.dev/api/v2/signingCert
    // for an ephemeral X.509 cert.  The cert's SAN encodes the workflow identity.
    throw new Error(
      "STAGED-ADVISORY: Fulcio keyless signing not yet wired. " +
        "Provide signingKeyPem + publicKeyPem for dev mode, or set oidcToken for CI."
    );
  }

  // 4. Build hashedrekord body
  const body: HashedRekordBody = {
    apiVersion: "0.0.1",
    kind: "hashedrekord",
    spec: {
      data: {
        hash: {
          algorithm: "sha256",
          value: hashHex,
        },
      },
      signature: {
        content: sigB64,
        publicKey: {
          // Rekor expects base64-encoded PEM (the PEM string itself, base64-encoded)
          content: Buffer.from(pubKeyPem).toString("base64"),
        },
      },
    },
  };

  // 5. POST to Rekor
  const controller = new AbortController();
  const timer = setTimeout(
    () => controller.abort(),
    opts.timeoutMs ?? 10_000
  );

  let response: Response;
  try {
    response = await fetch(url, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Accept: "application/json",
      },
      body: JSON.stringify({
        body: encodeBody(body),
      }),
      signal: controller.signal,
    });
  } finally {
    clearTimeout(timer);
  }

  if (!response.ok) {
    const text = await response.text();
    throw new Error(
      `Rekor submission failed: HTTP ${response.status}${text}`
    );
  }

  const entry = (await response.json()) as RekorEntry;

  // 6. Extract UUID (the single key in the response object)
  const uuid = Object.keys(entry)[0];
  if (!uuid) throw new Error("Rekor returned empty entry object");

  const entryData = entry[uuid];
  const logIndex = entryData.logIndex;
  const integratedTime = entryData.integratedTime;

  // Extract treeID from the logID (treeID is embedded in Rekor's logID for sharded logs)
  // logID is the hex-encoded SHA-256 of the log's public key — not the treeID.
  // For the public instance treeID is in the /api/v1/log response.
  // We embed the uuid (which encodes treeID+logIndex) as the canonical reference.
  const treeID = uuid.split("").slice(0, 16).join(""); // first 16 hex chars = treeID portion

  const rootHash =
    entryData.verification?.inclusionProof?.rootHash ?? "pending";

  const result: RekorSubmitResult = {
    uuid,
    logIndex,
    integratedTime,
    treeID,
    inclusionProofRootHash: rootHash,
    rekorEntryUrl: `${base}/api/${API_VERSION}/log/entries/${uuid}`,
    payloadHash: hashHex,
    submittedAt: new Date().toISOString(),
  };

  return result;
}

// ---------------------------------------------------------------------------
// JSONL side-car emitter
// ---------------------------------------------------------------------------

/**
 * Augment an existing SZL receipt JSONL line with Rekor attestation metadata.
 *
 * The augmented record is suitable for appending to the JSONL chain and acts
 * as an independently auditable anchor: any party holding the receipt can
 * reproduce the Rekor inclusion proof via:
 *   rekor-cli get --uuid <uuid> --rekor_server https://rekor.sigstore.dev
 */
export function augmentReceiptWithRekor(
  receipt: Record<string, unknown>,
  rekorResult: RekorSubmitResult
): Record<string, unknown> {
  return {
    ...receipt,
    rekorAttestation: {
      uuid: rekorResult.uuid,
      logIndex: rekorResult.logIndex,
      integratedTime: rekorResult.integratedTime,
      inclusionProofRootHash: rekorResult.inclusionProofRootHash,
      entryUrl: rekorResult.rekorEntryUrl,
      payloadHash: rekorResult.payloadHash,
      submittedAt: rekorResult.submittedAt,
      // Verification command for auditors:
      verifyCmd: `rekor-cli get --uuid ${rekorResult.uuid} --rekor_server https://rekor.sigstore.dev`,
    },
  };
}

// ---------------------------------------------------------------------------
// Pepr policy hook (exported for use in Pepr admission controller)
// ---------------------------------------------------------------------------

/**
 * Pepr-compatible policy hook.  Drop this into a Pepr When().Mutate() chain.
 *
 * Usage in Pepr policy file:
 *   import { emitRekorAttestation } from "./sigstore/rekor_submit";
 *
 *   When(SZLReceipt).IsCreated().Mutate(async (receipt) => {
 *     const result = await emitRekorAttestation(receipt.Raw.spec.envelope, peprOpts);
 *     receipt.SetAnnotation("szl.io/rekor-uuid", result.uuid);
 *     receipt.SetAnnotation("szl.io/rekor-log-index", String(result.logIndex));
 *   });
 */
export async function emitRekorAttestation(
  envelope: DSSEEnvelope,
  opts: RekorSubmitOptions
): Promise<RekorSubmitResult> {
  return submitDSSEToRekor(envelope, opts);
}

// ---------------------------------------------------------------------------
// CLI entry point (ts-node / tsx)
// ---------------------------------------------------------------------------

if (import.meta.url === `file://${process.argv[1]}`) {
  (async () => {
    const envelopePath = process.argv[2];
    if (!envelopePath) {
      console.error("Usage: tsx rekor_submit.ts <envelope.json> [--staging]");
      process.exit(1);
    }
    const envelope: DSSEEnvelope = JSON.parse(readFileSync(envelopePath, "utf8"));
    const { privateKeyPem, publicKeyPem } = generateDevKeyPair();
    const staging = process.argv.includes("--staging");

    console.log("Submitting DSSE envelope to Rekor...");
    const result = await submitDSSEToRekor(envelope, {
      signingKeyPem: privateKeyPem,
      publicKeyPem,
      staging,
    });

    console.log(JSON.stringify(result, null, 2));
    console.log(`\nVerify with:\n  ${result.rekorEntryUrl}`);
  })().catch((e) => {
    console.error(e);
    process.exit(1);
  });
}