audit-labs/gh-attest

GitHub Audit Evidence Extractor

clone: git clone https://gitbay.org/audit-labs/gh-attest.git

v1.0.2: src/github-app.ts · raw

 1import { SignJWT, importPKCS8 } from "jose";
 2
 3const GITHUB_API = "https://api.github.com";
 4
 5// GitHub issues App private keys as PKCS#1 ("BEGIN RSA PRIVATE KEY"), but
 6// jose/WebCrypto only import PKCS#8. PKCS#8 is the PKCS#1 DER wrapped in a
 7// PrivateKeyInfo envelope (version + rsaEncryption AlgorithmIdentifier), so
 8// wrap it ourselves rather than making every installer openssl-convert the
 9// key they downloaded.
10function pkcs1PemToPkcs8Pem(pem: string): string {
11  const base64 = pem.replace(/-----(BEGIN|END) RSA PRIVATE KEY-----/g, "").replace(/\s/g, "");
12  const pkcs1 = Uint8Array.from(atob(base64), (c) => c.codePointAt(0) ?? 0);
13
14  const derLength = (length: number): number[] => {
15    if (length < 0x80) return [length];
16    const bytes: number[] = [];
17    for (let remaining = length; remaining > 0; remaining >>= 8) bytes.unshift(remaining & 0xff);
18    return [0x80 | bytes.length, ...bytes];
19  };
20
21  const version = [0x02, 0x01, 0x00];
22  const rsaEncryptionAlgId = [
23    0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00,
24  ];
25  const octetString = [0x04, ...derLength(pkcs1.length)];
26  const contentLength = version.length + rsaEncryptionAlgId.length + octetString.length + pkcs1.length;
27  const pkcs8 = new Uint8Array([
28    0x30, ...derLength(contentLength),
29    ...version, ...rsaEncryptionAlgId, ...octetString, ...pkcs1,
30  ]);
31
32  let binary = "";
33  for (const byte of pkcs8) binary += String.fromCodePoint(byte);
34  return `-----BEGIN PRIVATE KEY-----\n${btoa(binary)}\n-----END PRIVATE KEY-----`;
35}
36
37export async function createAppJwt(appId: string, privateKeyPem: string): Promise<string> {
38  const pem = privateKeyPem.includes("BEGIN RSA PRIVATE KEY")
39    ? pkcs1PemToPkcs8Pem(privateKeyPem)
40    : privateKeyPem;
41  const privateKey = await importPKCS8(pem, "RS256");
42  const now = Math.floor(Date.now() / 1000);
43
44  return new SignJWT({})
45    .setProtectedHeader({ alg: "RS256" })
46    .setIssuedAt(now - 60) // GitHub allows up to 60s of clock drift
47    .setExpirationTime(now + 600) // max 10 minutes
48    .setIssuer(appId)
49    .sign(privateKey);
50}
51
52export async function getInstallationToken(appJwt: string, installationId: number): Promise<string> {
53  const res = await fetch(`${GITHUB_API}/app/installations/${installationId}/access_tokens`, {
54    method: "POST",
55    headers: {
56      Authorization: `Bearer ${appJwt}`,
57      Accept: "application/vnd.github+json",
58      "User-Agent": "gh-attest",
59    },
60  });
61
62  if (!res.ok) {
63    throw new Error(`Failed to mint installation token for ${installationId} (${res.status})`);
64  }
65
66  const data = (await res.json()) as { token: string };
67  return data.token;
68}