From 9c09739aa036f37426d7e81ba4e0b01e5d3a90ed Mon Sep 17 00:00:00 2001 From: "wille.io" Date: Sat, 23 Mar 2024 01:19:08 +0100 Subject: [PATCH] refactor architecture; more reliable challenges; fix readme; cli: fix domains with subdomains --- .gitignore | 4 +- README.md | 4 +- acme.ts | 722 +++++++++++++++++++++++++++++++++++++---------------- cli.ts | 45 ++-- 4 files changed, 530 insertions(+), 245 deletions(-) diff --git a/.gitignore b/.gitignore index 9f95971..2ddd8f5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ *.crt *.pem -deno.lock \ No newline at end of file +deno.lock +.deno-acme/ +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md index 8787b9e..e581ad0 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Use the CLI as a standalone acme client, or use the acme.ts library to use it in ## CLI How to get & use the CLI: ``` -sudo deno install -A --allow-read=. --allow-write=. --allow-net --name acme --root /usr/local/ https://deno.land/x/acme@v0.3.0/cli.ts +sudo deno install -A --allow-read=. --allow-write=. --allow-net --name acme --root /usr/local/ https://deno.land/x/acme@v0.3.1/cli.ts # http challenge: sudo acme http example.com,subdomain.example.com # cloudflare dns challenge: @@ -27,7 +27,7 @@ sudo acme cloudflare example.com,subdomain.example.com ## Library To use acme as a library in your application, add the following: ``` -import * as ACME from "https://deno.land/x/acme@v0.3.0/acme.ts" +import * as ACME from "https://deno.land/x/acme@v0.3.1/acme.ts" // http challenge: const { domainCertificates } = await ACME.getCertificatesWithHttp("example.com", "https://acme-staging-v02.api.letsencrypt.org/directory"); diff --git a/acme.ts b/acme.ts index 2ea07de..2ab53f1 100644 --- a/acme.ts +++ b/acme.ts @@ -1,33 +1,36 @@ -import * as jose from "https://deno.land/x/jose@v4.14.4/index.ts"; -import { serve } from "https://deno.land/std@0.193.0/http/server.ts"; +import * as jose from "https://deno.land/x/jose@v5.2.3/index.ts"; import { KJUR, KEYUTIL } from "npm:jsrsasign"; +import { serve } from "https://deno.land/std@0.193.0/http/server.ts"; import { encode as encodeBase64Url } from "https://deno.land/std@0.193.0/encoding/base64url.ts"; import { decode as decodeHex } from "https://deno.land/std@0.193.0/encoding/hex.ts"; -type JoseAccountKeys = { publicKey: jose.KeyLike, privateKey: jose.KeyLike, exists: boolean }; - - -// exports - - export interface Domain { domainName: string, subdomains?: string[] }; export type DomainCertificate = { domainName: string, subdomains?: string[], pemCertificate: string, pemPublicKey: string, pemPrivateKey: string }; -export type AccountKeys = { pemPublicKey: string, pemPrivateKey: string }; - +export type AccountKeys = { privateKeyPEM: string, publicKeyPEM: string }; +export interface CSRInfo +{ + countryCode: string; + organization: string; +} -async function getAccountKeys(pemAccountKeys?: AccountKeys) +enum ACMEStatus { - if (pemAccountKeys) - { - return { - publicKey: await jose.importSPKI(pemAccountKeys.pemPublicKey, "ES256", { extractable: true }), - privateKey: await jose.importPKCS8(pemAccountKeys.pemPrivateKey, "ES256", { extractable: true }), - exists: true, - } satisfies JoseAccountKeys; - } + pending = "pending", + processing = "processing", + valid = "valid", +} +type Nonce = string; +type AcmeDirectoryUrls = { newAccount: string, newNonce: string, newOrder: string }; +type Auth = { challengeUrl: string, keyAuth: string, token: string, authUrl: string }; +type Kid = string; + - return { ...(await jose.generateKeyPair('ES256', { extractable: true })), exists: false } satisfies JoseAccountKeys; +async function createSession(acmeDirectoryUrl: string, options?: { pemAccountKeys?: AccountKeys, email?: string }): Promise +{ + return (options?.pemAccountKeys) + ? await ACMESession.login(options.pemAccountKeys.privateKeyPEM, options.pemAccountKeys.publicKeyPEM, acmeDirectoryUrl) + : await ACMESession.register(acmeDirectoryUrl, options?.email); } @@ -44,7 +47,11 @@ export async function getCertificatesWithHttp(domains: Domain[], acmeDirectoryUr pemAccountKeys?: AccountKeys, csrInfo?: CSRInfo }) : Promise<{domainCertificates: DomainCertificate[], pemAccountKeys: AccountKeys}> { - return new ACMEHttp(await getAccountKeys(options?.pemAccountKeys), domains, acmeDirectoryUrl, options?.yourEmail, options?.csrInfo).getCertificates(); + const session = await createSession(acmeDirectoryUrl, { pemAccountKeys: options?.pemAccountKeys, email: options?.yourEmail }); + return { + domainCertificates: await new ACMEHttp(session, domains, options?.yourEmail, options?.csrInfo).getCertificates(), + pemAccountKeys: await session.exportAccount(), + }; } @@ -61,142 +68,218 @@ export async function getCertificatesWithCloudflare(bearer: string, domains: Dom pemAccountKeys?: AccountKeys, csrInfo?: CSRInfo }) : Promise<{domainCertificates: DomainCertificate[], pemAccountKeys: AccountKeys}> { - return new ACMECloudflare(bearer, await getAccountKeys(options?.pemAccountKeys), domains, acmeDirectoryUrl, options?.yourEmail, options?.csrInfo).getCertificates(); + const session = await createSession(acmeDirectoryUrl, { pemAccountKeys: options?.pemAccountKeys, email: options?.yourEmail }); + return { + domainCertificates: await new ACMECloudflare(bearer, session, domains, options?.yourEmail, options?.csrInfo).getCertificates(), + pemAccountKeys: await session.exportAccount(), + }; } -export interface CSRInfo +function orError(message: string): never { - countryCode: string; - organization: string; + throw new Error(message); } -// end of exports +function stringToNumberOrNull(value: string | null): number | null +{ + if (!value) + { + return null; + } + const ret = Number(value); + return (isNaN(ret) ? null : ret); +} -type Nonce = string; -type AcmeDirectoryUrls = { newAccount: string, newNonce: string, newOrder: string }; -type Auth = { challengeUrl: string, keyAuth: string, token: string, authUrl: string }; +class ACMEAccount +{ + public privateKey: jose.KeyLike; + public publicKey: jose.KeyLike; + public publicKeyJWK: jose.JWK; + public kid: Kid; + public async exportAccount(): Promise + { + return { privateKeyPEM: await jose.exportPKCS8(this.privateKey), publicKeyPEM: await jose.exportSPKI(this.publicKey) }; + } -function orError(message: string): never -{ - throw new Error(message); + public constructor(privateKey: jose.KeyLike, publicKey: jose.KeyLike, publicKeyJWK: jose.JWK, kid: Kid) + { + this.privateKey = privateKey; + this.publicKey = publicKey; + this.publicKeyJWK = publicKeyJWK; + this.kid = kid; + } } -abstract class ACMEBase + +export class ACMESession { - public constructor(challengeType: string, accountKeys: JoseAccountKeys, - domains: Domain[], acmeDirectoryUrl: string, email?: string, csrInfo?: CSRInfo) + private nonce: string; + private account: ACMEAccount; + private acmeDirectoryUrls: AcmeDirectoryUrls; + + + public jwk(): jose.JWK { - this.nonce = null; - this.accountKeys = accountKeys; - this.email = email; - this.kid = null; - this.domains = domains; - this.acmeDirectoryUrl = acmeDirectoryUrl; - this.csr = csrInfo;// || { countryCode: "US", organization: "deno-acme" }; - this.challengeType = challengeType; + return this.account.publicKeyJWK; } - public async getCertificates() - : Promise<{domainCertificates: DomainCertificate[], pemAccountKeys: AccountKeys}> + public directory(): AcmeDirectoryUrls { - const acmeDirectoryUrls: AcmeDirectoryUrls = await this.processAcmeDirectory(); + return this.acmeDirectoryUrls; // TODO: copy + } - await this.getNonce(acmeDirectoryUrls.newNonce); - const jwk = await this.createAccount(acmeDirectoryUrls.newAccount); - const domainCertificates: DomainCertificate[] = []; + public exportAccount(): Promise + { + return this.account.exportAccount(); + } - for (const domain of this.domains) + + private static async processAcmeDirectory(acmeDirectoryUrl: string): Promise + { + const res = await fetch(acmeDirectoryUrl); + await checkResponseStatus(res, 200); + const urls : AcmeDirectoryUrls = await res.json(); + + const acmeDirectoryUrls: AcmeDirectoryUrls = { - try - { - const { finalizeUrl, authUrls, orderUrl } = await this.newOrder(domain, acmeDirectoryUrls.newOrder); + newNonce: getStringFromJson("newNonce", urls), + newAccount: getStringFromJson("newAccount", urls), + newOrder: getStringFromJson("newOrder", urls), + }; - const auths: Auth[] = []; - for (const authUrl of authUrls) - { - auths.push(await this.newAuth(authUrl, jwk)); - } + console.log("acmeDirectoryUrls", acmeDirectoryUrls); + return acmeDirectoryUrls; + } - await this.newChallenges(domain, auths); - const { domainPublicKey, domainPrivateKey } = await this.newFinalize(domain, finalizeUrl); - const { certificateUrl } = await this.newReorder(orderUrl); - const cert = await this.getCert(certificateUrl); + private static async getNonce(url: string): Promise + { + console.debug("getNonce"); + const res = await fetch(url, + { + method: "HEAD", + }); - domainCertificates.push( - { - domainName: domain.domainName, - subdomains: domain.subdomains, - pemCertificate: cert, - pemPublicKey: domainPublicKey, - pemPrivateKey: domainPrivateKey, - }); - } - catch(e) + await checkResponseStatus(res, 200); + + const nonce = getNonceFromResponse(res); + console.debug("nonce", nonce); + return nonce; + } + + + private constructor(currentNonce: Nonce, account: ACMEAccount, acmeDirectoryUrls: AcmeDirectoryUrls) + { + this.nonce = currentNonce; + this.account = account; + this.acmeDirectoryUrls = acmeDirectoryUrls; + } + + + public static async login(privateKeyPEM: string, publicKeyPEM: string, acmeDirectoryUrl: string): Promise + { + console.debug("login"); + + const privateKey = await jose.importPKCS8(privateKeyPEM, "ES256", { extractable: true }); // TODO: false? + const publicKey = await jose.importSPKI(publicKeyPEM, "ES256", { extractable: true }); // s.a.a. + + const jwk = await jose.exportJWK(publicKey); + const acmeDirectoryUrls: AcmeDirectoryUrls = await this.processAcmeDirectory(acmeDirectoryUrl); + const fistNonce = await this.getNonce(acmeDirectoryUrls.newNonce); + + const { nonce, res } = await this.purePost(fistNonce, acmeDirectoryUrls.newAccount, privateKey, { - // get all other domains if one fails - console.error(`ACME: failed to order certificate for domain '${domain.domainName}': ${e}`); - //throw e; + payload: + { + onlyReturnExisting: true + }, + additionalProtectedHeaderValues: { jwk }, + expectedStatus: 200, + expectedAcmeStatus: ACMEStatus.valid, } - } + ); + // TODO: 7.3.3. ? - const pemAccountKeys: AccountKeys = - { - pemPublicKey: await jose.exportSPKI(this.accountKeys.publicKey), - pemPrivateKey: await jose.exportPKCS8(this.accountKeys.privateKey), - }; + const kid = getHeaderFromResponse(res, "location"); + console.debug("kid", kid); - return { domainCertificates, pemAccountKeys }; + return new ACMESession(nonce, new ACMEAccount(privateKey, publicKey, jwk, kid), acmeDirectoryUrls); } + public static async register(acmeDirectoryUrl: string, email?: string): Promise + { + console.debug("register"); - // private - private nonce: Nonce | null; - private accountKeys: JoseAccountKeys; - private email: string | undefined; - private kid: string | null; - private domains: Domain[]; - private acmeDirectoryUrl: string; - private csr: CSRInfo | undefined; - private challengeType: string; + const { privateKey, publicKey } = await jose.generateKeyPair("ES256", { extractable: true }); + const jwk = await jose.exportJWK(publicKey); + const acmeDirectoryUrls: AcmeDirectoryUrls = await this.processAcmeDirectory(acmeDirectoryUrl); + const firstNonce = await this.getNonce(acmeDirectoryUrls.newNonce); - private async processAcmeDirectory() - { - const res = await fetch(this.acmeDirectoryUrl); - await checkResponseStatus(res, 200); - //console.log("this.acmeDirectoryUrls", this.acmeDirectoryUrls); - return await res.json() as AcmeDirectoryUrls; + const { res, nonce } = await this.purePost(firstNonce, acmeDirectoryUrls.newAccount, privateKey, + { + payload: + { + termsOfServiceAgreed: true, + contact: (email ? [ `mailto:${email}`, ] : null), + }, + additionalProtectedHeaderValues: { jwk }, + expectedStatus: 201, + expectedAcmeStatus: ACMEStatus.valid, + } + ); + + const kid = getHeaderFromResponse(res, "location"); + console.debug("kid", kid); + + return new ACMESession(nonce, new ACMEAccount( privateKey, publicKey, jwk, kid), acmeDirectoryUrls); } - protected async post(url: string, payload: string | Record = "", - expectedStatus: number | number[] = 200, additionalProtectedHeaderValues?: Record) - : Promise + protected static async purePost(nonce: Nonce, url: string, privateKey: jose.KeyLike, + options?: { kid?: Kid, payload?: string | Record, + expectedStatus?: number | number[], additionalProtectedHeaderValues?: Record, + expectedAcmeStatus?: ACMEStatus | ACMEStatus[] }) + : Promise<{ res: Response, nonce: Nonce }> { + const payload = options?.payload || ""; + const expectedStatus = (options?.expectedStatus !== undefined) ? options?.expectedStatus : 200; const payloadString = (typeof(payload) === "string") ? payload : JSON.stringify(payload); + const header = + { + alg: 'ES256', + b64: true, + nonce, + url, + ...(options?.kid ? { kid: options.kid } : {}), + ...options?.additionalProtectedHeaderValues, + }; + + console.debug("header", header); + const jws = await new jose.FlattenedSign( new TextEncoder().encode(payloadString)) - .setProtectedHeader( - { - alg: 'ES256', - b64: true, - nonce: this.nonce, - url: url, - ...(this.kid ? { kid: this.kid } : {}), - ...additionalProtectedHeaderValues, - }) - .sign(this.accountKeys.privateKey); + .setProtectedHeader(header) + .sign(privateKey); + + console.debug("post", url); + + + // TODO: deactivate me after debugging: + /* cut here */ + const res = await (async (url: string, jws: unknown) => + { + // stop here */ const res = await fetch(url, { method: "POST", @@ -205,55 +288,241 @@ abstract class ACMEBase "Content-Type": "application/jose+json", }, body: JSON.stringify(jws), - }); + } + ); + /* cut here */ + const clone = res.clone(); + + if (["application/json","application/problem+json"].includes(getHeaderFromResponse(res, "content-type").toLowerCase())) + { + console.debug("json", await res.json()); + } + else + { + console.debug("text", await res.text()); + } + return clone; + })(url, jws); + // stop here */ + + + console.debug("res", res.headers, res.statusText); + + nonce = getNonceFromResponse(res); + console.debug("nonce", nonce); + + const contentType = getHeaderFromResponse(res, "content-type").toLowerCase(); + console.debug("contentType", contentType); + + if (contentType === "application/pem-certificate-chain") // downloading cert + { + return { res, nonce }; + } + + if (contentType !== "application/json") + { + if (contentType === "application/problem+json") + { + const json = await res.json(); + throw new Error(`acme server sent an error (with http status ${res.status}): \r\nType: ${ getValueFromJsonOrNull("type", json) || orError("") } \r\nDetails: ${ getValueFromJsonOrNull("detail", json) }`); + } - await checkResponseStatus(res, ...(Array.isArray(expectedStatus) ? expectedStatus : [expectedStatus])); //Array.isArray(expectedStatus) ? expectedStatus as number[] : [expectedStatus as number]); + throw new Error(`acme server sent malformed non-json response! '${await res.text()}'`); + } - this.nonce = getNonceFromResponse(res); + await checkResponseStatus(res, ...(Array.isArray(expectedStatus) ? expectedStatus : [expectedStatus])); - //console.log("post", url, res.headers, res.statusText); + if (options?.expectedAcmeStatus) + { + const expectedAcmeStatus = (Array.isArray(options.expectedAcmeStatus) ? options.expectedAcmeStatus : [options.expectedAcmeStatus]).map((status) => status as string); - return res; + const clone = res.clone(); + + const json = await res.json(); + const status = getStringFromJson("status", json); + + if (!expectedAcmeStatus.includes(status)) + { + throw new Error(`acme server answered with status '${status}', but '${expectedAcmeStatus.join("', '")}' was expected! ${JSON.stringify(json)}`); + } + + return { res: clone, nonce }; + } + + return { res, nonce }; } - private async getNonce(url: string) + public async post(url: string, options?: { payload?: string | Record, + expectedStatus?: number | number[], additionalProtectedHeaderValues?: Record, + expectedAcmeStatus?: ACMEStatus | ACMEStatus[], + waitForAcmeStatus?: boolean }) + : Promise { - //console.debug("> nonce"); - const res = await fetch(url, + const maxWaitTime = 80; + let waitTime = 0; + const wait = !!options?.waitForAcmeStatus; + + do { - method: "HEAD", - }); + if (waitTime >= maxWaitTime) + { + throw new Error(`acme server didn't change status after polling for ${waitTime} seconds - giving up`); + } - await checkResponseStatus(res, 200); + const { nonce, res } = await ACMESession.purePost(this.nonce, url, this.account.privateKey, + { + kid: this.account.kid, + ...options, + }); + this.nonce = nonce; + + + // already done by purePost + // const clone = res.clone(); + // const json = await res.json(); - this.nonce = getNonceFromResponse(res); + // const status = getStringFromJson("status", json); + + // if (status === "invalid") + // { + // throw new Error("acme server answered with status 'invalid'! " + JSON.stringify(json)); + // } + + + if (options?.waitForAcmeStatus) + { + const clone = res.clone(); + const json = await res.json(); + + const status = getStringFromJson("status", json); + + // already done by purePost + // if (status === "invalid") + // { + // throw new Error("acme server answered with status 'invalid'! " + JSON.stringify(json)); + // } + + if (["pending", "processing"].includes(status)) // TODO: special 'pending' handling? + { + const retryAfter = stringToNumberOrNull(res.headers.get("retry-after")) || 2; + console.debug("retry-after = ", retryAfter); + console.log(`acme status is '${status}' - trying again in ${retryAfter} seconds`); + waitTime += retryAfter; + await waitSeconds(retryAfter); + continue; + } + + if (status === "valid") + { + return clone; + } + + throw new Error("acme server sent unknown status! " + status); + } + + return res; + } + while (wait); + + // deno-lint-ignore no-unreachable + throw new Error(""); // deno lint either wants an (unreachable) ending return or an unknown return type } +} - private async newAuth(authUrl: string, jwk: jose.JWK): Promise - { - //console.debug("> newAuth"); +abstract class ACMEBase +{ + protected session: ACMESession; + protected email: string | undefined; + protected domains: Domain[]; + protected csr: CSRInfo | undefined; + protected challengeType: string; - //console.log("post auth", authUrl); - const res = await this.post(authUrl); - const json = await res.json(); - //console.log("auth json", json); + public constructor(challengeType: string, session: ACMESession, + domains: Domain[], email?: string, csrInfo?: CSRInfo) + { + this.session = session; + this.email = email; + this.domains = domains; + this.csr = csrInfo;// || { countryCode: "US", organization: "deno-acme" }; + this.challengeType = challengeType; + } + - const status = getStringFromJson("status", json); + public async getCertificates() + : Promise + { + const domainCertificates: DomainCertificate[] = []; - if (!["pending", "valid"].includes(status)) + for (const domain of this.domains) { - throw new Error(`order status not 'valid' or 'pending', but '${status}' - response: ${JSON.stringify(json)}`); // TODO: error message instead of full json + try + { + const { finalizeUrl, authUrls, orderUrl } = await this.newOrder(domain); + + const auths: Auth[] = []; + for (const authUrl of authUrls) + { + auths.push(await this.newAuth(authUrl)); + } + + await this.newChallenges(domain, auths); + + // check order + const x = await this.session.post(orderUrl); + console.debug("order", await x.json()); + + + const { domainPublicKey, domainPrivateKey } = await this.newFinalize(domain, finalizeUrl); + const { certificateUrl } = await this.newReorder(orderUrl); + const cert = await this.getCert(certificateUrl); + + domainCertificates.push( + { + domainName: domain.domainName, + subdomains: domain.subdomains, + pemCertificate: cert, + pemPublicKey: domainPublicKey, + pemPrivateKey: domainPrivateKey, + }); + } + catch(e) + { + // get all other domains if one fails + console.error(`ACME: failed to order certificate for domain '${domain.domainName}': \r\n${e}`); + //throw e; + } } + return domainCertificates; + } + + + private async newAuth(authUrl: string): Promise + { + console.debug("> newAuth"); + + console.debug("post auth", authUrl); + const res = await this.session.post(authUrl, { expectedAcmeStatus: [ ACMEStatus.pending, ACMEStatus.valid ] }); + + const json = await res.json(); + console.debug("auth json", json); + + // const status = getStringFromJson("status", json); + + // if (!["pending", "valid"].includes(status)) + // { + // throw new Error(`order status not 'valid' or 'pending', but '${status}' - response: ${JSON.stringify(json)}`); // TODO: error message instead of full json + // } + type Challenge = { type: string, url: string, token: string }; const challenges: Challenge[] = getValueFromJson("challenges", json) as Challenge[]; - //console.log("challenges", challenges); + console.debug("challenges", challenges); const challenge = challenges.find(obj => obj.type === this.challengeType); - //console.log("chosen challenge", challenge); + console.debug("selected challenge", challenge); if (!challenge) { @@ -269,15 +538,15 @@ abstract class ACMEBase throw new Error(`newAuth: no suitable token in ${this.challengeType} challenge received from acme server!`); } - const keyAuth = challenge.token + '.' + await jose.calculateJwkThumbprint(jwk); + const keyAuth = challenge.token + '.' + await jose.calculateJwkThumbprint(this.session.jwk()); return { challengeUrl: challenge.url, keyAuth: keyAuth, token: challenge.token, authUrl }; } - private async newOrder(domain: Domain, url: string) + private async newOrder(domain: Domain) { - //console.debug("> newOrder"); + console.debug("> newOrder"); const domainName = domain.domainName; @@ -288,10 +557,14 @@ abstract class ACMEBase ]; //console.log("post new order", url); - const res = await this.post(url, + const res = await this.session.post(this.session.directory().newOrder, { - "identifiers": identifiersArray, - }, 201); + payload: + { + identifiers: identifiersArray, + }, + expectedStatus: 201, + }); const orderUrl = checkUrl(res.headers.get("Location") || orError("Location header missing")); //console.log("orderUrl (from Location header)", orderUrl); @@ -312,53 +585,28 @@ abstract class ACMEBase } - private async createAccount(url: string)//: Promise - { - //console.debug("create account.. exists?", this.accountKeys.exists); - - const jwk = await jose.exportJWK(this.accountKeys.publicKey); - - // TODO: 7.3.3. ? - - const res = await this.post(url, - { - ...(this.accountKeys.exists ? - { - onlyReturnExisting: true - } - : - { - termsOfServiceAgreed: true, - contact: (this.email ? [ `mailto:${this.email}`, ] : null), - } - ) - }, - (this.accountKeys.exists ? 200 : 201), { jwk }); - - this.kid = getHeaderFromResponse(res, "location"); - //console.debug("kid", this.kid); - - return jwk; - } - - private async newReorder(orderUrl: string) { - //console.debug("> reorder"); + console.debug("> reorder"); - const res = await this.post(orderUrl); + const res = await this.session.post(orderUrl, + { + expectedAcmeStatus: [ ACMEStatus.processing, ACMEStatus.valid ], + waitForAcmeStatus: true + } + ); const json = await res.json(); const certificateUrl = getStringFromJson("certificate", json) as string; checkUrl(certificateUrl); - return { certificateUrl: certificateUrl }; + return { certificateUrl }; } private async newFinalize(domain: Domain, finalizeUrl: string) { - //console.debug("> finalize"); + console.debug("> finalize"); const { publicKey, privateKey } = await jose.generateKeyPair('ES256', { extractable: true }); // keys for the csr and the to-be requested certificate(s) const spkiPemPubCSR = await jose.exportSPKI(publicKey); @@ -390,34 +638,33 @@ abstract class ACMEBase const csrDer = encodeBase64Url(csrDerHexRaw); - const res = await this.post(finalizeUrl, { csr: csrDer }); - const json = await res.json(); - const status = getStringFromJson("status", json) as string; + const res = await this.session.post(finalizeUrl, + { + payload: + { + csr: csrDer + }, + expectedAcmeStatus: [ ACMEStatus.processing, ACMEStatus.valid ], + //waitForAcmeStatus: true + } + ); - if (["invalid", "pending"].includes(status)) - { - throw new Error(`newFinalize: acme server answered with status 'invalid' or 'pending': '${json}' - headers: '${res.headers}'`); - } + // const json = await res.json(); + // const status = getStringFromJson("status", json) as string; // pending means: 'The server does not believe that the client has fulfilled the requirements.' see https://datatracker.ietf.org/doc/html/rfc8555#section-7.4 // TODO: be able to re-do challenges on pending? - const certificateReady = (status === "valid"); // VERY unlikely + //const certificateReady = (status === "valid"); // VERY unlikely + // TODO: finalize if already valid // so status is valid or processing const orderUrl = getHeaderFromResponse(res, "location"); + console.debug("orderUrl", orderUrl); checkUrl(orderUrl); - if (!certificateReady) - { - const retryAfter: number = (parseInt(res.headers.get("retry-after") || "10") || 10) + 2; - - //console.log(`waiting ${retryAfter} seconds for the acme server to process our certificate...`); - await waitSeconds(retryAfter); - } - return { orderUrl: orderUrl, domainPublicKey: spkiPemPubCSR, domainPrivateKey: pkcs8PemPrvCSR /*, certificateReady: certificateReady*/ }; } @@ -425,9 +672,9 @@ abstract class ACMEBase private async getCert(certificateUrl: string): Promise { - //console.debug("> cert"); + console.debug("> cert"); - const res = await this.post(certificateUrl); + const res = await this.session.post(certificateUrl); const text = await res.text(); const certList = text.split("-----END CERTIFICATE-----").map(cert => cert + "-----END CERTIFICATE-----"); @@ -453,10 +700,10 @@ class ACMECloudflare extends ACMEBase private bearer: string; - public constructor(bearer: string, accountKeys: JoseAccountKeys, - domains: Domain[], acmeDirectoryUrl: string, email?: string, csrInfo?: CSRInfo) + public constructor(bearer: string, session: ACMESession, + domains: Domain[], email?: string, csrInfo?: CSRInfo) { - super("dns-01", accountKeys, domains, acmeDirectoryUrl, email, csrInfo); + super("dns-01", session, domains, email, csrInfo); this.bearer = bearer } @@ -464,11 +711,12 @@ class ACMECloudflare extends ACMEBase async newChallenges(domain: Domain, auths: Auth[]) { - //console.log("> newChallenges: " + auths.length); + console.debug("> newChallenges - count:" + auths.length); // find zone id of given zone const cloudflareZoneId = await (async (cloudflareZone) => { + // TODO: search with "name" parameter (site is TLD!) const rep = await fetch(`https://api.cloudflare.com/client/v4/zones`, { method: "GET", @@ -484,9 +732,11 @@ class ACMECloudflare extends ACMEBase } const json = await rep.json(); + console.debug("cloudflare zones json", json); const result = getValueFromJson("result", json) as { id: string, name: string }[]; + // TODO: site is the TLD ! for (const entry of result) { const id = getStringFromJson("id", entry); @@ -517,7 +767,7 @@ class ACMECloudflare extends ACMEBase { for (const auth of auths) { - //console.log("auth... with challenge url", auth.challengeUrl); + console.debug("auth... with challenge url", auth.challengeUrl); const dnsNames: string[] = [ domain.domainName, ...(domain.subdomains || []) ]; @@ -553,34 +803,39 @@ class ACMECloudflare extends ACMEBase } const json = await rep.json(); + console.debug("cloudflare create record json", json); const id = getStringFromJson("id", getValueFromJson("result", json) as Record); dnsRecordIds.push(id); - //console.log("cloudflare create dns record success", json.result.id);//, json); + console.log(`cloudflare create dns record for (sub)domain '${dnsName}' success - record content: '${txtRecordContent}' - record id: '${json.result.id}'`);//, json); } - //console.log("all cloudflare dns records created"); + console.log("all cloudflare dns records created"); } - // all auths done, now waiting for the order to be processed - //console.log("giving the acme server time (10s) to catch up..."); - //await waitSeconds(10); + // creating txt record(s) done - waiting for acme's dns to catch up + console.log("giving the acme server time (3s) to catch up..."); + await waitSeconds(3); // fire all challenges for (const auth of auths) { // tell acme server that the challenge has been solved - //console.log("post challenge", auth.challengeUrl); + console.log("post challenge", auth.challengeUrl); //const challengeResult = - await this.post(auth.challengeUrl, {}); - + await this.session.post(auth.challengeUrl, + { + payload: {}, + expectedAcmeStatus: [ ACMEStatus.processing, ACMEStatus.valid, ACMEStatus.pending ], + //waitForAcmeStatus: true + }); //const challengeJson = await challengeResult.json(); - //console.log("challenge json", challengeJson, "http status", challengeResult.status); + //console.debug("challenge json", challengeJson, "http status", challengeResult.status); //const challengeStatus = getStringFromJson("status", challengeJson); @@ -597,24 +852,32 @@ class ACMECloudflare extends ACMEBase // await waitSeconds(20); // TODO: respect 'Retry-After' header //console.log("post AUTH", auth.authUrl); - const authStatus = await this.post(auth.authUrl); + //const authStatus = + await this.session.post(auth.authUrl, + { + expectedAcmeStatus: [ ACMEStatus.processing, ACMEStatus.valid, ACMEStatus.pending ], + waitForAcmeStatus: true + } + ); + + // TODO: try again n times if error === "urn:ietf:params:acme:error:unauthorized" && (detail.startsWith("No TXT record found at") || detail.startsWith("Invalid TXT record [...]")) + // .. because acme's dns just might need to catch up first // if (!authStatus.ok) // { // throw new Error("Order status check failed: " + JSON.stringify(await authStatus.json())); // } - const authJson = await authStatus.json(); - //console.log("authJson", authJson); + // const authJson = await authStatus.json(); + // console.debug("authJson", authJson); - const status = getStringFromJson("status", authJson); + // const status = getStringFromJson("status", authJson); + // console.debug("status", status); - //console.log("status", status); - - if (!["pending", "valid"].includes(status)) - { - throw new Error(`response auth status not 'pending' or 'valid', but '${status}': response: ${JSON.stringify(authJson)}`); // TODO: error message instead of whole json - } + // if (!["pending", "valid"].includes(status)) + // { + // throw new Error(`response auth status not 'pending' or 'valid', but '${status}': response: ${JSON.stringify(authJson)}`); // TODO: error message instead of whole json + // } } } catch (err) @@ -628,6 +891,14 @@ class ACMECloudflare extends ACMEBase { try { + console.debug(`cloudflare dns record '${dnsRecordId}':`, await (await fetch(`https://api.cloudflare.com/client/v4/zones/${cloudflareZoneId}/dns_records/${dnsRecordId}`, + { + headers: + { + "authorization": "Bearer " + this.bearer, + }, + })).json()); + const rep = await fetch(`https://api.cloudflare.com/client/v4/zones/${cloudflareZoneId}/dns_records/${dnsRecordId}`, { method: "DELETE", @@ -649,17 +920,17 @@ class ACMECloudflare extends ACMEBase } } - //console.log("all auths done"); + console.log("all auths done"); } } class ACMEHttp extends ACMEBase { - public constructor(accountKeys: JoseAccountKeys, - domains: Domain[], acmeDirectoryUrl: string, email?: string, csrInfo?: CSRInfo) + public constructor(session: ACMESession, + domains: Domain[], email?: string, csrInfo?: CSRInfo) { - super("http-01", accountKeys, domains, acmeDirectoryUrl, email, csrInfo); + super("http-01", session, domains, email, csrInfo); } @@ -701,7 +972,7 @@ class ACMEHttp extends ACMEBase //console.log("webserver started, starting challenge..."); //console.log( - await this.post(challengeUrl, {}) + await this.session.post(challengeUrl, { payload: {} }) // ); ; @@ -792,6 +1063,14 @@ function getValueFromJson(key: string, json: Record): unknown } +function getValueFromJsonOrNull(key: string, json: Record): unknown +{ + if (!(key in json)) + throw new Error(`getValueFromJson: missing '${key}' in acme server response body`); + return json[key]; +} + + function checkUrl(url: string): string { if (!url.startsWith("https://")) @@ -814,6 +1093,7 @@ function getNonceFromResponse(res: Response): Nonce try { const nonce: string = getHeaderFromResponse(res, "Replay-Nonce"); + //console.debug("### NEW NONCE", nonce); return nonce as Nonce; } catch (_e) diff --git a/cli.ts b/cli.ts index cbb7f36..483cf71 100644 --- a/cli.ts +++ b/cli.ts @@ -24,23 +24,26 @@ async function shared1(accountDirectory: string, domainsWithSubdomains: string[] const domains: ACME.Domain[] = []; - for (const domainWithSubdomain of domainsWithSubdomains) + for (const _domainWithSubdomains of domainsWithSubdomains) { - const split = domainWithSubdomain.split(","); - const domainName = split[0]; - const subdomains = split.slice(1); + const domainWithSubdomains = _domainWithSubdomains.split(","); + //console.debug("domainWithSubdomains", domainWithSubdomains); - for (const subdomain of subdomains) - { - if (!subdomain.endsWith(domainName)) - { - command.showHelp(); - console.error("Subdomains need to end with the main domain's name."); - Deno.exit(1); - } - } + const domainName = domainWithSubdomains[0]; + const subdomains = domainWithSubdomains.slice(1); + //console.debug("domainName", domainName, "subdomains", subdomains); + + // for (const subdomain of subdomains) + // { + // console.debug("subdomain", subdomain); - //split[1]; + // if (!subdomain.endsWith(domainName)) + // { + // command.showHelp(); + // console.error("Subdomains need to end with the main domain's name."); + // Deno.exit(1); + // } + // } const domain = { @@ -63,7 +66,7 @@ async function shared1(accountDirectory: string, domainsWithSubdomains: string[] if (!pub || !prv) throw "no pub and / or prv"; - accountKeys = { pemPublicKey: pub, pemPrivateKey: prv }; + accountKeys = { privateKeyPEM: prv, publicKeyPEM: pub }; console.log("using existing account keys"); } @@ -90,13 +93,13 @@ async function shared2(accountDirectory: string, accountKeys: ACME.AccountKeys | { if (!accountKeys) { - await Deno.writeTextFile(accountDirectory + "/accountPrivateKey.pem", pemAccountKeys.pemPrivateKey); - await Deno.writeTextFile(accountDirectory + "/accountPublicKey.pem", pemAccountKeys.pemPublicKey); + await Deno.writeTextFile(accountDirectory + "/accountPrivateKey.pem", pemAccountKeys.privateKeyPEM); + await Deno.writeTextFile(accountDirectory + "/accountPublicKey.pem", pemAccountKeys.publicKeyPEM); console.log("saved new account keys"); } - //console.log("certs", domainCertificates); - //console.log("keys", pemAccountKeys); + // console.debug("certs", domainCertificates); + // console.debug("keys", pemAccountKeys); for (const domainCertificate of domainCertificates) { @@ -115,7 +118,7 @@ async function shared2(accountDirectory: string, accountKeys: ACME.AccountKeys | const command = new Command() .name("acme-cli") -.version("v0.3.0") +.version("v0.3.1") .description("Get certificates for your domains and or your domains their subdomains with the specified challenge type from an acme server. \r\n"+ "One certificate is created per challenge argument. \r\n"+ "You can either get a certificate for a domain *and* its subdomains or for a domain only (without subdomains). It is not possible to get a certificate with only subdomains (without its parent domain).\r\n"+ @@ -151,7 +154,7 @@ const command = new Command() .env("CLOUDFLARE_BEARER=", "Your Cloudflare API token bearer to list, create and delte temporary acme TXT records with.", { required: true }) .meta("Cloudflare API version", "4") .arguments("") -.example("Get two certificates", "example.com:example.com,subdomain.example.com example2.com:subdomain.example2.com,subdomain2.example2.com") +.example("Get two certificates", "example.com,subdomain.example.com subdomain2.example.com") .action(async (options, ...args) => { const { email, directory, accountDirectory, cloudflareBearer } = options;