audit-labs/gh-attest
GitHub Audit Evidence Extractor
clone: git clone https://gitbay.org/audit-labs/gh-attest.git
v1.0.4: 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}