mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
Add challenge service
This commit is contained in:
@@ -10,7 +10,7 @@ import { dropConstraintIfExists } from "@app/db/migrations/utils/dropConstraintI
|
||||
const OLD_ENROLLMENT_TYPE_CHECK_CONSTRAINT = "pki_certificate_profiles_enrollmentType_check";
|
||||
const NEW_ENROLLMENT_TYPE_CHECK_CONSTRAINT = "pki_certificate_profiles_enrollment_type_check";
|
||||
|
||||
const PUBLIC_KEY_ALG_INDEX = "pki_acme_accounts_publicKey_alg_index";
|
||||
const PUBLIC_KEY_THUMBPRINT_ALG_INDEX = "pki_acme_accounts_publicKey_thumbprint_alg_index";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
// Create PkiAcmeEnrollmentConfig table
|
||||
@@ -56,12 +56,14 @@ export async function up(knex: Knex): Promise<void> {
|
||||
// Multi-value emails array
|
||||
t.specificType("emails", "text[]").notNullable();
|
||||
|
||||
// TODO: make public key a string instead of jsonb to make indexing much easier
|
||||
// Public key (JWK format)
|
||||
t.jsonb("publicKey").notNullable();
|
||||
// Public key thumbprint
|
||||
t.string("publicKeyThumbprint").notNullable();
|
||||
// The JWS algorithm used to sign the public key when creating the account, e.g. "RS256", "ES256", "PS256", etc.
|
||||
t.string("alg").notNullable();
|
||||
t.index(["publicKey", "alg"], PUBLIC_KEY_ALG_INDEX);
|
||||
// We may need to look up existing accounts by public key thumbprint and algorithm, so we index on both of them.
|
||||
t.index(["publicKeyThumbprint", "alg"], PUBLIC_KEY_THUMBPRINT_ALG_INDEX);
|
||||
|
||||
t.timestamps(true, true, true);
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ export const PkiAcmeAccountsSchema = z.object({
|
||||
profileId: z.string().uuid(),
|
||||
emails: z.string().array(),
|
||||
publicKey: z.unknown(),
|
||||
publicKeyThumbprint: z.string(),
|
||||
alg: z.string(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
|
||||
@@ -11,13 +11,13 @@ export const PkiAcmeAuthsSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
accountId: z.string().uuid(),
|
||||
status: z.string(),
|
||||
token: z.date().nullable().optional(),
|
||||
identifierType: z.string(),
|
||||
identifierValue: z.string(),
|
||||
expiresAt: z.date(),
|
||||
certificateId: z.string().uuid().nullable().optional(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
token: z.string().nullable().optional()
|
||||
updatedAt: z.date()
|
||||
});
|
||||
|
||||
export type TPkiAcmeAuths = z.infer<typeof PkiAcmeAuthsSchema>;
|
||||
|
||||
@@ -10,12 +10,12 @@ import { TImmutableDBKeys } from "./models";
|
||||
export const PkiAcmeOrdersSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
accountId: z.string().uuid(),
|
||||
status: z.string(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
notBefore: z.date().nullable().optional(),
|
||||
notAfter: z.date().nullable().optional(),
|
||||
expiresAt: z.date()
|
||||
expiresAt: z.date(),
|
||||
status: z.string(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
});
|
||||
|
||||
export type TPkiAcmeOrders = z.infer<typeof PkiAcmeOrdersSchema>;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { DatabaseError } from "@app/lib/errors";
|
||||
import { ormify, selectAllTableCols } from "@app/lib/knex";
|
||||
import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex";
|
||||
import { Knex } from "knex";
|
||||
|
||||
export type TPkiAcmeChallengeDALFactory = ReturnType<typeof pkiAcmeChallengeDALFactory>;
|
||||
@@ -34,8 +34,49 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => {
|
||||
throw new DatabaseError({ error, name: "Find PKI ACME challenge by account id, auth id and challenge id" });
|
||||
}
|
||||
};
|
||||
const findByIdWithAuthForUpdate = async (id: string, tx?: Knex) => {
|
||||
const rows = await (tx || db)(TableName.PkiAcmeChallenge)
|
||||
.join(TableName.PkiAcmeAuth, `${TableName.PkiAcmeChallenge}.authId`, `${TableName.PkiAcmeAuth}.id`)
|
||||
.select(
|
||||
selectAllTableCols(TableName.PkiAcmeChallenge),
|
||||
db.ref("id").withSchema(TableName.PkiAcmeAuth).as("authId"),
|
||||
db.ref("token").withSchema(TableName.PkiAcmeAuth).as("authToken"),
|
||||
db.ref("status").withSchema(TableName.PkiAcmeAuth).as("authStatus"),
|
||||
db.ref("identifierType").withSchema(TableName.PkiAcmeAuth).as("authIdentifierType"),
|
||||
db.ref("identifierValue").withSchema(TableName.PkiAcmeAuth).as("authIdentifierValue"),
|
||||
db.ref("expiresAt").withSchema(TableName.PkiAcmeAuth).as("authExpiresAt")
|
||||
)
|
||||
// For all challenges, acquire update lock on the auth to avoid race conditions
|
||||
.forUpdate(TableName.PkiAcmeAuth)
|
||||
.where(`${TableName.PkiAcmeChallenge}.id`, id);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return sqlNestRelationships({
|
||||
data: rows,
|
||||
key: "id",
|
||||
parentMapper: (row) => row,
|
||||
childrenMapper: [
|
||||
{
|
||||
key: "authId",
|
||||
label: "auth" as const,
|
||||
mapper: ({ authId, authToken, authStatus, authIdentifierType, authIdentifierValue, authExpiresAt }) => ({
|
||||
id: authId,
|
||||
token: authToken,
|
||||
status: authStatus,
|
||||
identifierType: authIdentifierType,
|
||||
identifierValue: authIdentifierValue,
|
||||
expiresAt: authExpiresAt
|
||||
})
|
||||
}
|
||||
]
|
||||
})?.[0];
|
||||
};
|
||||
|
||||
return {
|
||||
...pkiAcmeChallengeOrm,
|
||||
findByAccountAuthAndChallengeIdWithToken
|
||||
findByAccountAuthAndChallengeIdWithToken,
|
||||
findByIdWithAuthForUpdate
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { TPkiAcmeChallengeDALFactory } from "./pki-acme-challenge-dal";
|
||||
import { AcmeAuthStatus, AcmeChallengeStatus, AcmeChallengeType } from "./pki-acme-schemas";
|
||||
import { TPkiAcmeChallengeServiceFactory } from "./pki-acme-types";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { calculateJwkThumbprint } from "jose";
|
||||
|
||||
type TPkiAcmeChallengeServiceFactoryDep = {
|
||||
acmeChallengeDAL: Pick<TPkiAcmeChallengeDALFactory, "transaction" | "findByIdWithAuthForUpdate">;
|
||||
};
|
||||
|
||||
export const pkiAcmeChallengeServiceFactory = ({
|
||||
acmeChallengeDAL
|
||||
}: TPkiAcmeChallengeServiceFactoryDep): TPkiAcmeChallengeServiceFactory => {
|
||||
const appCfg = getConfig();
|
||||
|
||||
const validateChallengeResponse = async (challengeId: string): Promise<void> => {
|
||||
return await acmeChallengeDAL.transaction(async (tx) => {
|
||||
const challenge = await acmeChallengeDAL.findByIdWithAuthForUpdate(challengeId, tx);
|
||||
if (!challenge) {
|
||||
throw new NotFoundError({ message: "ACME challenge not found" });
|
||||
}
|
||||
if (challenge.status !== AcmeChallengeStatus.Processing) {
|
||||
throw new BadRequestError({
|
||||
message: `ACME challenge is ${challenge.status} instead of ${AcmeChallengeStatus.Processing}`
|
||||
});
|
||||
}
|
||||
if (challenge.auth.expiresAt < new Date()) {
|
||||
throw new BadRequestError({ message: "ACME auth has expired" });
|
||||
}
|
||||
if (challenge.auth.status !== AcmeAuthStatus.Pending) {
|
||||
throw new BadRequestError({
|
||||
message: `ACME auth status is ${challenge.auth.status} instead of ${AcmeAuthStatus.Pending}`
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: support other challenge types here. Currently only HTTP-01 is supported
|
||||
if (challenge.type !== AcmeChallengeType.HTTP_01) {
|
||||
throw new BadRequestError({ message: "Only HTTP-01 challenges are supported for now" });
|
||||
}
|
||||
const baseUrl = `http://${challenge.auth.identifierValue}`;
|
||||
const actualBaseUrl = appCfg.isAcmeDevelopmentMode
|
||||
? `${baseUrl}:${appCfg.ACME_DEVELOPMENT_HTTP01_CHALLENGE_PORT}`
|
||||
: baseUrl;
|
||||
|
||||
const challengeUrl = new URL(`/.well-known/acme-challenge/${challenge.token}`, actualBaseUrl);
|
||||
const challengeResponse = await fetch(challengeUrl);
|
||||
if (challengeResponse.status !== 200) {
|
||||
throw new BadRequestError({ message: "ACME challenge response is not 200" });
|
||||
}
|
||||
const challengeResponseBody = await challengeResponse.text();
|
||||
const expectedChallengeResponseBody = `${challenge.token}.${challenge.auth.identifierValue}`;
|
||||
});
|
||||
};
|
||||
|
||||
return { validateChallengeResponse };
|
||||
};
|
||||
@@ -174,3 +174,5 @@ export type TPkiAcmeServiceFactory = {
|
||||
challengeId: string;
|
||||
}) => Promise<TAcmeResponse<TRespondToAcmeChallengeResponse>>;
|
||||
};
|
||||
|
||||
export type TPkiAcmeChallengeServiceFactory = {};
|
||||
|
||||
@@ -106,6 +106,8 @@ const envSchema = z
|
||||
HTTPS_ENABLED: zodStrBool,
|
||||
ROTATION_DEVELOPMENT_MODE: zodStrBool.default("false").optional(),
|
||||
DAILY_RESOURCE_CLEAN_UP_DEVELOPMENT_MODE: zodStrBool.default("false").optional(),
|
||||
ACME_DEVELOPMENT_MODE: zodStrBool.default("false").optional(),
|
||||
ACME_DEVELOPMENT_HTTP01_CHALLENGE_PORT: z.coerce.number().default(8087),
|
||||
// smtp options
|
||||
SMTP_HOST: zpStr(z.string().optional()),
|
||||
SMTP_IGNORE_TLS: zodStrBool.default("false"),
|
||||
@@ -384,6 +386,7 @@ const envSchema = z
|
||||
(data.NODE_ENV === "development" && data.ROTATION_DEVELOPMENT_MODE) || data.NODE_ENV === "test",
|
||||
isDailyResourceCleanUpDevelopmentMode:
|
||||
data.NODE_ENV === "development" && data.DAILY_RESOURCE_CLEAN_UP_DEVELOPMENT_MODE,
|
||||
isAcmeDevelopmentMode: data.NODE_ENV === "development" && data.ACME_DEVELOPMENT_MODE,
|
||||
isProductionMode: data.NODE_ENV === "production" || IS_PACKAGED,
|
||||
isRedisSentinelMode: Boolean(data.REDIS_SENTINEL_HOSTS),
|
||||
REDIS_SENTINEL_HOSTS: data.REDIS_SENTINEL_HOSTS?.trim()
|
||||
|
||||
Reference in New Issue
Block a user