diff --git a/apps/provider-proxy/package.json b/apps/provider-proxy/package.json index 4fa3a0f7c..6549f32b5 100644 --- a/apps/provider-proxy/package.json +++ b/apps/provider-proxy/package.json @@ -21,6 +21,7 @@ }, "dependencies": { "@akashnetwork/net": "*", + "async-sema": "^3.1.1", "bech32": "^2.0.0", "cors": "^2.8.5", "express": "^4.18.2", diff --git a/apps/provider-proxy/src/container.ts b/apps/provider-proxy/src/container.ts index 3c4ab9594..32f44222a 100644 --- a/apps/provider-proxy/src/container.ts +++ b/apps/provider-proxy/src/container.ts @@ -1,5 +1,6 @@ import { netConfig, SupportedChainNetworks } from "@akashnetwork/net"; +import { CertificateValidator, createCertificateValidatorInstrumentation } from "./services/CertificateValidator"; import { ProviderProxy } from "./services/ProviderProxy"; import { ProviderService } from "./services/ProviderService"; import { WebsocketStats } from "./services/WebsocketStats"; @@ -13,9 +14,15 @@ const providerService = new ProviderService((network: SupportedChainNetworks) => // @see https://github.com/mswjs/msw/discussions/2416 return process.env.TEST_CHAIN_NETWORK_URL || netConfig.getBaseAPIUrl(network); }, fetch); -const providerProxy = new ProviderProxy(Date.now, providerService); +const certificateValidator = new CertificateValidator( + Date.now, + providerService, + process.env.NODE_ENV === "test" ? undefined : createCertificateValidatorInstrumentation(console) +); +const providerProxy = new ProviderProxy(certificateValidator); export const container = { wsStats, - providerProxy + providerProxy, + certificateValidator }; diff --git a/apps/provider-proxy/src/services/CertificateValidator.ts b/apps/provider-proxy/src/services/CertificateValidator.ts new file mode 100644 index 000000000..304bb8f92 --- /dev/null +++ b/apps/provider-proxy/src/services/CertificateValidator.ts @@ -0,0 +1,152 @@ +import { SupportedChainNetworks } from "@akashnetwork/net"; +import { Sema } from "async-sema"; +import { bech32 } from "bech32"; +import { X509Certificate } from "crypto"; +import { LRUCache } from "lru-cache"; + +import { ProviderService } from "./ProviderService"; + +export class CertificateValidator { + private readonly knownCertificatesCache = new LRUCache({ + max: 100_000, + ttl: 30 * 60 * 1000 + }); + private readonly locks: Record = {}; + + constructor( + private readonly now: () => number, + private readonly providerService: ProviderService, + private readonly instrumentation?: CertificateValidatorIntrumentation + ) {} + + async validate(certificate: X509Certificate, network: SupportedChainNetworks, providerAddress: string): Promise { + const now = this.now(); + const validationResult = validateCertificateAttrs(certificate, now); + + if (validationResult.ok === false) { + this.instrumentation?.onInvalidAttrs?.(certificate, network, providerAddress, now, validationResult); + return validationResult; + } + + const providerCertificate = await this.getProviderCertificate(certificate, network, providerAddress); + if (!providerCertificate) { + this.instrumentation?.onUnknownCert?.(certificate, network, providerAddress); + return { ok: false, code: "unknownCertificate" }; + } + + if (providerCertificate.fingerprint256 !== certificate.fingerprint256) { + this.instrumentation?.onInvalidFingerprint?.(certificate, network, providerAddress, providerCertificate); + return { ok: false, code: "fingerprintMismatch" }; + } + + this.instrumentation?.onValidationSuccess?.(certificate, network, providerAddress, now); + + return { ok: true }; + } + + private async getProviderCertificate(cert: X509Certificate, network: SupportedChainNetworks, providerAddress: string): Promise { + const key = `${network}.${providerAddress}.${cert.serialNumber}`; + + this.locks[key] ??= new Sema(1); + + try { + await this.locks[key].acquire(); + if (!this.knownCertificatesCache.has(key)) { + const certificate = await this.providerService.getCertificate(network, providerAddress, cert.serialNumber); + this.knownCertificatesCache.set(key, certificate); + return certificate; + } + + return this.knownCertificatesCache.get(key); + } finally { + this.locks[key].release(); + delete this.locks[key]; + } + } +} + +export type CertValidationResult = { ok: true } | CertValidationResultError; +export type CertValidationResultError = { + ok: false; + code: "validInFuture" | "expired" | "invalidSerialNumber" | "notSelfSigned" | "CommonNameIsNotBech32" | "unknownCertificate" | "fingerprintMismatch"; +}; +export interface CertificateValidatorIntrumentation { + onValidationSuccess?(certificate: X509Certificate, network: SupportedChainNetworks, providerAddress: string, now: number): void; + onInvalidAttrs?( + certificate: X509Certificate, + network: SupportedChainNetworks, + providerAddress: string, + now: number, + validationResult: CertValidationResultError + ): void; + onUnknownCert?(certificate: X509Certificate, network: SupportedChainNetworks, providerAddress: string): void; + onInvalidFingerprint?(certificate: X509Certificate, network: SupportedChainNetworks, providerAddress: string, providerCertificate: X509Certificate): void; +} + +function validateCertificateAttrs(cert: X509Certificate, now: number): CertValidationResult { + if (new Date(cert.validFrom).getTime() > now) { + return { + ok: false, + code: "validInFuture" + }; + } + + if (new Date(cert.validTo).getTime() < now) { + return { + ok: false, + code: "expired" + }; + } + + if (!cert.serialNumber?.trim()) { + return { + ok: false, + code: "invalidSerialNumber" + }; + } + + if (cert.issuer !== cert.subject) { + return { + ok: false, + code: "notSelfSigned" + }; + } + + const commonName = parseCertSubject(cert.subject, "CN"); + if (!commonName || !bech32.decodeUnsafe(commonName)) { + return { + ok: false, + code: "CommonNameIsNotBech32" + }; + } + + return { ok: true }; +} + +function parseCertSubject(subject: string, attr: string): string | null { + const attrPrefix = `${attr}=`; + const index = subject.indexOf(attrPrefix); + if (index === -1) return null; + + const endIndex = subject.indexOf("\n", index); + if (endIndex === -1) return subject.slice(index); + + return subject.slice(index + attrPrefix.length, endIndex); +} + +export const createCertificateValidatorInstrumentation = (logger: typeof console): CertificateValidatorIntrumentation => ({ + onValidationSuccess(certificate, network, providerAddress, now) { + logger.log(`Successfully validated ${certificate.serialNumber} in ${network} for "${providerAddress}" at ${now}`); + }, + onInvalidAttrs(certificate, network, providerAddress, now, result) { + logger.log(`Certificate ${certificate.serialNumber} is invalid in ${network} for "${providerAddress}" because ${result.code} at ${now}`); + }, + onInvalidFingerprint(certificate, network, providerAddress, providerCertificate) { + logger.log( + `Certificate ${certificate.serialNumber} (${certificate.fingerprint256}) fingerprint does not match fingerprint in ${network} for ${providerAddress}: ${providerCertificate.fingerprint256}` + ); + }, + onUnknownCert(certificate, network, providerAddress) { + logger.log(`Certificate ${certificate.serialNumber} does not have corresponding certificate in ${network} for ${providerAddress}`); + } +}); diff --git a/apps/provider-proxy/src/services/ProviderProxy.ts b/apps/provider-proxy/src/services/ProviderProxy.ts index 28700c956..00928f0e0 100644 --- a/apps/provider-proxy/src/services/ProviderProxy.ts +++ b/apps/provider-proxy/src/services/ProviderProxy.ts @@ -1,34 +1,31 @@ import { SupportedChainNetworks } from "@akashnetwork/net"; -import { X509Certificate } from "crypto"; import { IncomingMessage } from "http"; import https, { RequestOptions } from "https"; import { LRUCache } from "lru-cache"; import { TLSSocket } from "tls"; -import { CertValidationResultError, validateCertificate } from "../utils/validateCertificate"; -import { ProviderService } from "./ProviderService"; +import { CertificateValidator, CertValidationResultError } from "./CertificateValidator"; export class ProviderProxy { - private readonly knownCertificatesCache = new LRUCache({ - max: 100_000, - ttl: 30 * 60 * 1000 - }); + /** + * Cache agents in order to control TLS session resumption + */ private readonly agentsCache = new LRUCache({ - max: 100_000 + max: 1_000_000 }); - constructor( - private readonly now: () => number, - private readonly providerService: ProviderService - ) {} + constructor(private readonly certificateValidator: CertificateValidator) {} connect(url: string, options: ProxyConnectOptions): Promise { - const agent = this.getHttpsAgent(options.network, options.providerAddress, { + const agentOptions: TLSChainAgentOptions = { timeout: options.timeout, + rejectUnauthorized: false, cert: options.cert, key: options.key, - rejectUnauthorized: false - }); + chainNetwork: options.network, + providerAddress: options.providerAddress + }; + const agent = this.getHttpsAgent(agentOptions); return new Promise((resolve, reject) => { const req = https.request( url, @@ -59,18 +56,15 @@ export class ProviderProxy { const didHandshake = !!serverCert; if (didHandshake && options.network && options.providerAddress) { - const validationResult = validateCertificate(serverCert, this.now()); + const validationResult = await this.certificateValidator.validate(serverCert, options.network, options.providerAddress); if (validationResult.ok === false) { + // remove agent from cache to destroy TLS session to force TLS handshake on the next call + this.agentsCache.delete(genAgentsCacheKey(agentOptions)); + resolve({ ok: false, code: "invalidCertificate", reason: validationResult.code }); + req.off("error", reject); req.destroy(); - this.agentsCache.delete(`${options.network}.${options.providerAddress}`); - return resolve({ ok: false, code: "invalidCertificate", reason: validationResult.code }); - } - - const isKnown = await this.isKnownCertificate(serverCert, options.network, options.providerAddress); - if (!isKnown) { - req.destroy(); - this.agentsCache.delete(`${options.network}.${options.providerAddress}`); - return resolve({ ok: false, code: "invalidCertificate", reason: "unknownCertificate" }); + agent.destroy(); + return; } } @@ -82,9 +76,7 @@ export class ProviderProxy { ); if (!req.reusedSocket) { - req.on("error", (error: (Error & { code: string }) | undefined) => { - reject(error); - }); + req.on("error", reject); req.on("timeout", () => { // here we are just notified that response take more than specified in request options timeout // then we manually destroy request and it drops connection and @@ -98,23 +90,12 @@ export class ProviderProxy { }); } - private async isKnownCertificate(cert: X509Certificate, network: SupportedChainNetworks, providerAddress: string): Promise { - const key = `${network}.${providerAddress}.${cert.serialNumber}`; - - if (!this.knownCertificatesCache.has(key)) { - const hasCertificate = await this.providerService.hasCertificate(network, providerAddress, cert.serialNumber); - this.knownCertificatesCache.set(key, hasCertificate); - return hasCertificate; - } - - return this.knownCertificatesCache.get(key); - } - - private getHttpsAgent(network: SupportedChainNetworks, providerAddress: string, options: https.AgentOptions): https.Agent { - const key = `${network}.${providerAddress}`; + private getHttpsAgent(options: TLSChainAgentOptions): https.Agent { + const key = genAgentsCacheKey(options); if (!this.agentsCache.has(key)) { - const agent = new https.Agent(options); + const { chainNetwork, providerAddress, ...agentOptions } = options; + const agent = new https.Agent(agentOptions); this.agentsCache.set(key, agent); return agent; } @@ -123,6 +104,10 @@ export class ProviderProxy { } } +function genAgentsCacheKey(options: TLSChainAgentOptions): string { + return `${options.chainNetwork}:${options.providerAddress}:${options.cert}:${options.key}`; +} + export interface ProxyConnectOptions extends Pick { body?: BodyInit; headers?: Record; @@ -140,5 +125,10 @@ interface ProxyConnectionResultSuccess { } type ProxyConnectionResultError = - | { ok: false; code: "invalidCertificate"; reason: CertValidationResultError["code"] | "unknownCertificate" } + | { ok: false; code: "invalidCertificate"; reason: CertValidationResultError["code"] } | { ok: false; code: "insecureConnection" }; + +interface TLSChainAgentOptions extends https.AgentOptions { + chainNetwork: SupportedChainNetworks; + providerAddress: string; +} diff --git a/apps/provider-proxy/src/services/ProviderService.ts b/apps/provider-proxy/src/services/ProviderService.ts index 1043042ff..883922ede 100644 --- a/apps/provider-proxy/src/services/ProviderService.ts +++ b/apps/provider-proxy/src/services/ProviderService.ts @@ -1,4 +1,5 @@ import { SupportedChainNetworks } from "@akashnetwork/net"; +import { X509Certificate } from "crypto"; import { httpRetry } from "../utils/retry"; @@ -8,7 +9,7 @@ export class ProviderService { private readonly fetch: typeof global.fetch ) {} - async hasCertificate(network: SupportedChainNetworks, providerAddress: string, serialNumber: string): Promise { + async getCertificate(network: SupportedChainNetworks, providerAddress: string, serialNumber: string): Promise { const queryParams = new URLSearchParams({ "filter.state": "valid", "filter.owner": providerAddress, @@ -22,13 +23,17 @@ export class ProviderService { if (response.status >= 200 && response.status < 300) { const body = (await response.json()) as KnownCertificatesResponseBody; - return body.certificates.length === 1; + return body.certificates.length === 1 ? new X509Certificate(atob(body.certificates[0].certificate.cert)) : null; } - return false; + return null; } } interface KnownCertificatesResponseBody { - certificates: unknown[]; + certificates: Array<{ + certificate: { + cert: string; + }; + }>; } diff --git a/apps/provider-proxy/src/utils/retry.ts b/apps/provider-proxy/src/utils/retry.ts index ed81c91c8..af38d9e23 100644 --- a/apps/provider-proxy/src/utils/retry.ts +++ b/apps/provider-proxy/src/utils/retry.ts @@ -1,7 +1,7 @@ import { setTimeout } from "timers/promises"; export const httpRetry = (callback: () => Promise, options: HttpRetryOptions): Promise => { - return retryWithBackoff(callback, options.retryIf, options.maxRetries || 5, 0); + return retryWithBackoff(callback, options.retryIf, options.maxRetries || 3, 0); }; export interface HttpRetryOptions { diff --git a/apps/provider-proxy/src/utils/validateCertificate.ts b/apps/provider-proxy/src/utils/validateCertificate.ts deleted file mode 100644 index ea331c0b4..000000000 --- a/apps/provider-proxy/src/utils/validateCertificate.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { bech32 } from "bech32"; -import { X509Certificate } from "crypto"; - -export function validateCertificate(cert: X509Certificate, now: number): CertValidationResult { - if (new Date(cert.validFrom).getTime() > now) { - return { - ok: false, - code: "validInFuture" - }; - } - - if (new Date(cert.validTo).getTime() < now) { - return { - ok: false, - code: "expired" - }; - } - - if (!cert.serialNumber?.trim()) { - return { - ok: false, - code: "invalidSerialNumber" - }; - } - - if (cert.issuer !== cert.subject) { - return { - ok: false, - code: "notSelfSigned" - }; - } - - const commonName = parseCertSubject(cert.subject, "CN"); - if (!commonName || !bech32.decodeUnsafe(commonName)) { - return { - ok: false, - code: "CommonNameIsNotBech32" - }; - } - - return { ok: true }; -} - -export type CertValidationResult = CertValidationResultError | { ok: true }; -export type CertValidationResultError = { ok: false; code: "validInFuture" | "expired" | "invalidSerialNumber" | "notSelfSigned" | "CommonNameIsNotBech32" }; - -function parseCertSubject(subject: string, attr: string): string | null { - const attrPrefix = `${attr}=`; - const index = subject.indexOf(attrPrefix); - if (index === -1) return null; - - const endIndex = subject.indexOf("\n", index); - if (endIndex === -1) return subject.slice(index); - - return subject.slice(index + attrPrefix.length, endIndex); -} diff --git a/apps/provider-proxy/test/services/CertificateValidator.spec.ts b/apps/provider-proxy/test/services/CertificateValidator.spec.ts new file mode 100644 index 000000000..5aa580717 --- /dev/null +++ b/apps/provider-proxy/test/services/CertificateValidator.spec.ts @@ -0,0 +1,170 @@ +import { X509Certificate } from "crypto"; + +import { CertificateValidator, CertificateValidatorIntrumentation, CertValidationResultError } from "../../src/services/CertificateValidator"; +import { ProviderService } from "../../src/services/ProviderService"; +import { createX509CertPair } from "../seeders/createX509CertPair"; + +describe(CertificateValidator.name, () => { + const ONE_MINUTE = 60 * 1000; + + it('returns "unknownCertificate" error result if provider certificate cannot be found', async () => { + const { cert } = createX509CertPair({ + validFrom: new Date(), + validTo: new Date(Date.now() + ONE_MINUTE), + commonName: "akash1rk090a6mq9gvm0h6ljf8kz8mrxglwwxsk4srxh", + serialNumber: "177831BE7F249E66" + }); + const getCertificate = jest.fn(() => Promise.resolve(null)); + const validator = setup({ getCertificate }); + + const result = (await validator.validate(cert, "mainnet", "provider")) as CertValidationResultError; + + expect(result.ok).toBe(false); + expect(result.code).toBe("unknownCertificate"); + expect(getCertificate).toHaveBeenCalledWith("mainnet", "provider", cert.serialNumber); + }); + + it('returns "fingerprintMismatch" error result if certificate fingerprint does not match', async () => { + const { cert } = createX509CertPair({ + validFrom: new Date(), + validTo: new Date(Date.now() + ONE_MINUTE), + commonName: "akash1rk090a6mq9gvm0h6ljf8kz8mrxglwwxsk4srxh", + serialNumber: "177831BE7F249E66" + }); + const getCertificate = jest.fn(() => + Promise.resolve( + createX509CertPair({ + validFrom: new Date(), + validTo: new Date(Date.now() + ONE_MINUTE), + commonName: "akash1rk090a6mq9gvm0h6ljf8kz8mrxglwwxsk4srxh", + serialNumber: "177831BE7F249E61" + }).cert + ) + ); + const validator = setup({ getCertificate }); + + const result = (await validator.validate(cert, "mainnet", "provider")) as CertValidationResultError; + + expect(result.ok).toBe(false); + expect(result.code).toBe("fingerprintMismatch"); + expect(getCertificate).toHaveBeenCalledWith("mainnet", "provider", cert.serialNumber); + }); + + it("caches provider certificate per network, provider and serial number", async () => { + const { cert } = createX509CertPair({ + validFrom: new Date(), + validTo: new Date(Date.now() + ONE_MINUTE), + commonName: "akash1rk090a6mq9gvm0h6ljf8kz8mrxglwwxsk4srxh", + serialNumber: "177831BE7F249E66" + }); + const { cert: anotherCert } = createX509CertPair({ + validFrom: new Date(), + validTo: new Date(Date.now() + 2 * ONE_MINUTE), + commonName: "akash1rk090a6mq9gvm0h6ljf8kz8mrxglwwxsk4srxh", + serialNumber: "177831BE7F249E11" + }); + const getCertificate = jest.fn().mockReturnValueOnce(Promise.resolve(cert)).mockReturnValueOnce(Promise.resolve(anotherCert)).mockReturnValue(null); + const validator = setup({ getCertificate }); + + let result = await validator.validate(cert, "mainnet", "provider"); + expect(getCertificate).toHaveBeenCalledWith("mainnet", "provider", cert.serialNumber); + expect(result.ok).toBe(true); + + result = await validator.validate(cert, "mainnet", "provider"); + expect(getCertificate).toHaveBeenCalledTimes(1); + expect(result.ok).toBe(true); + + result = await validator.validate(anotherCert, "sandbox", "provider"); + expect(getCertificate).toHaveBeenCalledWith("sandbox", "provider", anotherCert.serialNumber); + expect(result.ok).toBe(true); + + result = await validator.validate(anotherCert, "sandbox", "provider"); + expect(getCertificate).toHaveBeenCalledTimes(2); + expect(result.ok).toBe(true); + }); + + it("returns error if certificate is issued for future use", async () => { + const validFrom = new Date(); + const { cert } = createX509CertPair({ validFrom }); + const validator = setup({ now: validFrom.getTime() - ONE_MINUTE }); + + const result = (await validator.validate(cert, "mainnet", "provider")) as CertValidationResultError; + + expect(result.ok).toBe(false); + expect(result.code).toBe("validInFuture"); + }); + + it("returns error if certificate expired", async () => { + const validFrom = new Date(); + const validTo = new Date(validFrom.getTime() + 60 * 1000); + const { cert } = createX509CertPair({ validFrom, validTo }); + const validator = setup({ now: validTo.getTime() + ONE_MINUTE }); + + const result = (await validator.validate(cert, "mainnet", "provider")) as CertValidationResultError; + + expect(result.ok).toBe(false); + expect(result.code).toBe("expired"); + }); + + it("returns error if certificate does not have serial number", async () => { + const cert = Object.create(createX509CertPair().cert) as X509Certificate; + Object.defineProperty(cert, "serialNumber", { get: () => "" }); + const validator = setup(); + + const result = (await validator.validate(cert, "mainnet", "provider")) as CertValidationResultError; + + expect(result.ok).toBe(false); + expect(result.code).toBe("invalidSerialNumber"); + }); + + it("returns error if certificate subject common name is not in bech32 format", async () => { + const { cert } = createX509CertPair({ commonName: "test.com" }); + const validator = setup(); + + const result = (await validator.validate(cert, "mainnet", "provider")) as CertValidationResultError; + + expect(result.ok).toBe(false); + expect(result.code).toBe("CommonNameIsNotBech32"); + }); + + it("returns error if certificate subject common name is not in bech32 format", async () => { + const { cert } = createX509CertPair(); + const validator = setup(); + + const result = (await validator.validate(cert, "mainnet", "provider")) as CertValidationResultError; + + expect(result.ok).toBe(false); + expect(result.code).toBe("CommonNameIsNotBech32"); + }); + + it("returns successful result if all criterias above are met", async () => { + const { cert } = createX509CertPair({ + validFrom: new Date(), + validTo: new Date(Date.now() + ONE_MINUTE), + commonName: "akash1rk090a6mq9gvm0h6ljf8kz8mrxglwwxsk4srxh", + serialNumber: "177831BE7F249E66" + }); + const getCertificate = () => Promise.resolve(cert); + const validator = setup({ getCertificate }); + + const result = await validator.validate(cert, "mainnet", "provider"); + + expect(result.ok).toBe(true); + }); + + function setup(params?: Params) { + return new CertificateValidator( + () => params?.now ?? Date.now(), + { + getCertificate: params?.getCertificate || jest.fn() + } as ProviderService, + params?.instrumentation + ); + } + + interface Params { + now?: number; + getCertificate?: ProviderService["getCertificate"]; + instrumentation?: CertificateValidatorIntrumentation; + } +}); diff --git a/apps/provider-proxy/test/services/ProviderService.spec.ts b/apps/provider-proxy/test/services/ProviderService.spec.ts index edaf4e295..04a229db7 100644 --- a/apps/provider-proxy/test/services/ProviderService.spec.ts +++ b/apps/provider-proxy/test/services/ProviderService.spec.ts @@ -1,4 +1,5 @@ import { ProviderService } from "../../src/services/ProviderService"; +import { CertificateOptions, createX509CertPair } from "../seeders/createX509CertPair"; describe(ProviderService.name, () => { describe("hasCertificate", () => { @@ -7,13 +8,20 @@ describe(ProviderService.name, () => { const service = setup({ getCertificates }); getCertificates.mockReturnValueOnce(new Response(JSON.stringify({ certificates: [] }), { status: 200 })); - expect(await service.hasCertificate("sandbox", "provider", "177831BE7F249E66")).toBe(false); + expect(await service.getCertificate("sandbox", "provider", "177831BE7F249E66")).toBe(null); expect(getCertificates).toHaveBeenCalledWith(expect.stringContaining("pagination.limit=1")); expect(getCertificates).toHaveBeenCalledWith(expect.stringContaining("filter.owner=provider")); expect(getCertificates).toHaveBeenCalledWith(expect.stringContaining("filter.serial=1691156354324274790")); - getCertificates.mockReturnValueOnce(new Response(JSON.stringify({ certificates: [{}] }), { status: 200 })); - expect(await service.hasCertificate("sandbox", "provider", "17B85C634EF9EB05")).toBe(true); + getCertificates.mockReturnValueOnce( + new Response( + JSON.stringify({ + certificates: [buildCertificate({ serialNumber: "17B85C634EF9EB05" })] + }), + { status: 200 } + ) + ); + expect(await service.getCertificate("sandbox", "provider", "17B85C634EF9EB05")).toHaveProperty("serialNumber", "17B85C634EF9EB05"); }); it("retries certificates request if it fails with response.status > 500", async () => { @@ -22,18 +30,25 @@ describe(ProviderService.name, () => { getCertificates.mockReturnValueOnce(new Response(JSON.stringify("Server error"), { status: 502 })); getCertificates.mockReturnValueOnce(new Response(JSON.stringify("Server error"), { status: 502 })); - getCertificates.mockReturnValueOnce(new Response(JSON.stringify({ certificates: [{}] }), { status: 200 })); - expect(await service.hasCertificate("sandbox", "provider", "17B85C634EF9EB05")).toBe(true); + getCertificates.mockReturnValueOnce( + new Response( + JSON.stringify({ + certificates: [buildCertificate({ serialNumber: "17B85C634EF9EB05" })] + }), + { status: 200 } + ) + ); + expect(await service.getCertificate("sandbox", "provider", "17B85C634EF9EB05")).toHaveProperty("serialNumber", "17B85C634EF9EB05"); expect(getCertificates).toHaveBeenCalledTimes(3); }); - it("retries certificate request 5 times and then fails in case of response.status > 500", async () => { + it("retries certificate request 3 times and then fails in case of response.status > 500", async () => { const getCertificates = jest.fn(); const service = setup({ getCertificates }); getCertificates.mockReturnValue(new Response(JSON.stringify("Server error"), { status: 502 })); - expect(await service.hasCertificate("sandbox", "provider", "17B85C634EF9EB05")).toBe(false); - expect(getCertificates).toHaveBeenCalledTimes(1 + 5); + expect(await service.getCertificate("sandbox", "provider", "17B85C634EF9EB05")).toBe(null); + expect(getCertificates).toHaveBeenCalledTimes(1 + 3); }, 7_000); it("returns false if certificates request fails with 500 and do not retry", async () => { @@ -41,7 +56,7 @@ describe(ProviderService.name, () => { const service = setup({ getCertificates }); getCertificates.mockReturnValueOnce(new Response(JSON.stringify("Server error"), { status: 500 })); - expect(await service.hasCertificate("sandbox", "provider", "17B85C634EF9EB05")).toBe(false); + expect(await service.getCertificate("sandbox", "provider", "17B85C634EF9EB05")).toBe(null); expect(getCertificates).toHaveBeenCalledTimes(1); }); }); @@ -56,4 +71,8 @@ describe(ProviderService.name, () => { interface Props { getCertificates(...args: unknown[]): Response; } + + function buildCertificate(params?: CertificateOptions) { + return { certificate: { cert: btoa(createX509CertPair(params).cert.toJSON()) } }; + } }); diff --git a/apps/provider-proxy/test/services/validateCertificate.spec.ts b/apps/provider-proxy/test/services/validateCertificate.spec.ts deleted file mode 100644 index c54f94121..000000000 --- a/apps/provider-proxy/test/services/validateCertificate.spec.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { X509Certificate } from "crypto"; - -import { CertValidationResultError, validateCertificate } from "../../src/utils/validateCertificate"; -import { createX509CertPair } from "../seeders/createX509CertPair"; - -describe(validateCertificate.name, () => { - const ONE_MINUTE = 60 * 1000; - - it("returns error if certificate is issued for future use", () => { - const validFrom = new Date(); - const { cert } = createX509CertPair({ validFrom }); - - const result = validateCertificate(cert, validFrom.getTime() - ONE_MINUTE) as CertValidationResultError; - - expect(result.ok).toBe(false); - expect(result.code).toBe("validInFuture"); - }); - - it("returns error if certificate expired", () => { - const validFrom = new Date(); - const validTo = new Date(validFrom.getTime() + 60 * 1000); - const { cert } = createX509CertPair({ validFrom, validTo }); - - const result = validateCertificate(cert, validTo.getTime() + ONE_MINUTE) as CertValidationResultError; - - expect(result.ok).toBe(false); - expect(result.code).toBe("expired"); - }); - - it("returns error if certificate does not have serial number", () => { - const cert = Object.create(createX509CertPair().cert) as X509Certificate; - Object.defineProperty(cert, "serialNumber", { get: () => "" }); - - const result = validateCertificate(cert, Date.now()) as CertValidationResultError; - - expect(result.ok).toBe(false); - expect(result.code).toBe("invalidSerialNumber"); - }); - - it("returns error if certificate subject common name is not in bech32 format", () => { - const { cert } = createX509CertPair({ commonName: "test.com" }); - const result = validateCertificate(cert, Date.now()) as CertValidationResultError; - - expect(result.ok).toBe(false); - expect(result.code).toBe("CommonNameIsNotBech32"); - }); - - it("returns error if certificate subject common name is not in bech32 format", () => { - const { cert } = createX509CertPair(); - const result = validateCertificate(cert, Date.now()) as CertValidationResultError; - - expect(result.ok).toBe(false); - expect(result.code).toBe("CommonNameIsNotBech32"); - }); - - it("returns successful result if all criterias above are met", () => { - const { cert } = createX509CertPair({ - validFrom: new Date(), - validTo: new Date(Date.now() + ONE_MINUTE), - commonName: "akash1rk090a6mq9gvm0h6ljf8kz8mrxglwwxsk4srxh", - serialNumber: "177831BE7F249E66" - }); - const result = validateCertificate(cert, Date.now()); - - expect(result.ok).toBe(true); - }); -}); diff --git a/package-lock.json b/package-lock.json index 36bbd098b..0743b6519 100644 --- a/package-lock.json +++ b/package-lock.json @@ -641,6 +641,7 @@ "license": "Apache-2.0", "dependencies": { "@akashnetwork/net": "*", + "async-sema": "^3.1.1", "bech32": "^2.0.0", "cors": "^2.8.5", "express": "^4.18.2", @@ -17414,7 +17415,8 @@ "node_modules/async-sema": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/async-sema/-/async-sema-3.1.1.tgz", - "integrity": "sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==" + "integrity": "sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==", + "license": "MIT" }, "node_modules/asynckit": { "version": "0.4.0",