Some checks failed
Cache: Publish Nix / Publish Nix Cache (push) Waiting to run
Build Rust / Cargo Test (push) Successful in 4m14s
Build Site / Next.js Build (push) Failing after 6s
Infra: OpenTofu / OpenTofu (grafana) (push) Successful in 5s
Lint Governance / BEP Metadata (push) Successful in 0s
Build: Android / Android Rust Core Stub (push) Failing after 23s
164 lines
4.9 KiB
JavaScript
Executable file
164 lines
4.9 KiB
JavaScript
Executable file
#!/usr/bin/env node
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { webcrypto } from "node:crypto";
|
|
|
|
const crypto = globalThis.crypto ?? webcrypto;
|
|
|
|
function b64url(input) {
|
|
const buf = Buffer.isBuffer(input) ? input : Buffer.from(input);
|
|
return buf
|
|
.toString("base64")
|
|
.replace(/=/g, "")
|
|
.replace(/\+/g, "-")
|
|
.replace(/\//g, "_");
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const out = { _: [] };
|
|
for (let i = 2; i < argv.length; i++) {
|
|
const arg = argv[i];
|
|
if (arg === "--help" || arg === "-h") out.help = true;
|
|
else if (arg.startsWith("--")) {
|
|
const key = arg.slice(2);
|
|
const value = argv[i + 1];
|
|
if (!value || value.startsWith("--")) throw new Error(`missing value for --${key}`);
|
|
out[key] = value;
|
|
i++;
|
|
} else {
|
|
out._.push(arg);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function makeJwt({ keyId, issuerId, keyPem }) {
|
|
const header = { alg: "ES256", kid: keyId, typ: "JWT" };
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const payload = { iss: issuerId, exp: now + 20 * 60, aud: "appstoreconnect-v1" };
|
|
const input = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`;
|
|
const pkcs8Der = Buffer.from(
|
|
keyPem.replace(/-----BEGIN PRIVATE KEY-----|-----END PRIVATE KEY-----|\s+/g, ""),
|
|
"base64",
|
|
);
|
|
const key = await crypto.subtle.importKey(
|
|
"pkcs8",
|
|
pkcs8Der,
|
|
{ name: "ECDSA", namedCurve: "P-256" },
|
|
false,
|
|
["sign"],
|
|
);
|
|
const sig = await crypto.subtle.sign(
|
|
{ name: "ECDSA", hash: "SHA-256" },
|
|
key,
|
|
new TextEncoder().encode(input),
|
|
);
|
|
return `${input}.${b64url(Buffer.from(sig))}`;
|
|
}
|
|
|
|
async function ascFetch(jwt, url, opts = {}) {
|
|
const res = await fetch(url, {
|
|
...opts,
|
|
headers: {
|
|
...(opts.headers ?? {}),
|
|
Authorization: `Bearer ${jwt}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
});
|
|
const text = await res.text();
|
|
let body;
|
|
try {
|
|
body = text ? JSON.parse(text) : {};
|
|
} catch {
|
|
body = { raw: text };
|
|
}
|
|
if (!res.ok) {
|
|
throw new Error(`ASC ${res.status} ${url}: ${JSON.stringify(body).slice(0, 2000)}`);
|
|
}
|
|
return body;
|
|
}
|
|
|
|
function certificateFileName(type, id) {
|
|
return `${type.toLowerCase().replace(/_/g, "-")}-${id}.cer`;
|
|
}
|
|
|
|
function csrContentForApi(csrFile) {
|
|
const bytes = fs.readFileSync(csrFile);
|
|
const text = bytes.toString("ascii");
|
|
const pemMatch = text.match(/-----BEGIN CERTIFICATE REQUEST-----([\s\S]+?)-----END CERTIFICATE REQUEST-----/);
|
|
if (pemMatch) {
|
|
return pemMatch[1].replace(/\s+/g, "");
|
|
}
|
|
return bytes.toString("base64");
|
|
}
|
|
|
|
async function main() {
|
|
const args = parseArgs(process.argv);
|
|
if (args.help) {
|
|
console.log("Usage: create-asc-certificate.mjs --key-id ID --issuer-id ID --key-file AuthKey.p8 --certificate-type IOS_DISTRIBUTION --csr-file file.csr --out-dir DIR");
|
|
process.exit(0);
|
|
}
|
|
|
|
const keyId = args["key-id"] ?? process.env.APP_STORE_CONNECT_KEY_ID;
|
|
const issuerId = args["issuer-id"] ?? process.env.APP_STORE_CONNECT_ISSUER_ID;
|
|
const keyFile = args["key-file"] ?? process.env.APP_STORE_CONNECT_KEY_FILE;
|
|
const certificateType = args["certificate-type"] ?? "IOS_DISTRIBUTION";
|
|
const csrFile = args["csr-file"];
|
|
const outDir = args["out-dir"] ?? ".";
|
|
const outFile = args["out-file"];
|
|
const metadataFile = args["metadata-file"];
|
|
|
|
if (!keyId || !issuerId || !keyFile || !csrFile) {
|
|
throw new Error("required: --key-id --issuer-id --key-file --csr-file");
|
|
}
|
|
|
|
const jwt = await makeJwt({
|
|
keyId,
|
|
issuerId,
|
|
keyPem: fs.readFileSync(keyFile, "utf8"),
|
|
});
|
|
const csrContent = csrContentForApi(csrFile);
|
|
const created = await ascFetch(jwt, "https://api.appstoreconnect.apple.com/v1/certificates", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
data: {
|
|
type: "certificates",
|
|
attributes: {
|
|
certificateType,
|
|
csrContent,
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
|
|
const cert = created?.data;
|
|
const id = cert?.id;
|
|
const content = cert?.attributes?.certificateContent;
|
|
if (!id || !content) {
|
|
throw new Error(`ASC response did not include certificate id/content: ${JSON.stringify(created).slice(0, 2000)}`);
|
|
}
|
|
|
|
fs.mkdirSync(outDir, { recursive: true });
|
|
const certificatePath = outFile ?? path.join(outDir, certificateFileName(certificateType, id));
|
|
fs.writeFileSync(certificatePath, Buffer.from(content, "base64"));
|
|
|
|
const metadata = {
|
|
id,
|
|
certificateType: cert?.attributes?.certificateType ?? certificateType,
|
|
displayName: cert?.attributes?.displayName,
|
|
name: cert?.attributes?.name,
|
|
platform: cert?.attributes?.platform,
|
|
expirationDate: cert?.attributes?.expirationDate,
|
|
serialNumber: cert?.attributes?.serialNumber,
|
|
certificatePath,
|
|
};
|
|
if (metadataFile) {
|
|
fs.writeFileSync(metadataFile, `${JSON.stringify(metadata, null, 2)}\n`);
|
|
}
|
|
process.stdout.write(`${JSON.stringify(metadata, null, 2)}\n`);
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(err?.stack || String(err));
|
|
process.exit(1);
|
|
});
|