mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-08 02:27:37 +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 OLD_ENROLLMENT_TYPE_CHECK_CONSTRAINT = "pki_certificate_profiles_enrollmentType_check";
|
||||||
const NEW_ENROLLMENT_TYPE_CHECK_CONSTRAINT = "pki_certificate_profiles_enrollment_type_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> {
|
export async function up(knex: Knex): Promise<void> {
|
||||||
// Create PkiAcmeEnrollmentConfig table
|
// Create PkiAcmeEnrollmentConfig table
|
||||||
@@ -56,12 +56,14 @@ export async function up(knex: Knex): Promise<void> {
|
|||||||
// Multi-value emails array
|
// Multi-value emails array
|
||||||
t.specificType("emails", "text[]").notNullable();
|
t.specificType("emails", "text[]").notNullable();
|
||||||
|
|
||||||
// TODO: make public key a string instead of jsonb to make indexing much easier
|
|
||||||
// Public key (JWK format)
|
// Public key (JWK format)
|
||||||
t.jsonb("publicKey").notNullable();
|
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.
|
// The JWS algorithm used to sign the public key when creating the account, e.g. "RS256", "ES256", "PS256", etc.
|
||||||
t.string("alg").notNullable();
|
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);
|
t.timestamps(true, true, true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const PkiAcmeAccountsSchema = z.object({
|
|||||||
profileId: z.string().uuid(),
|
profileId: z.string().uuid(),
|
||||||
emails: z.string().array(),
|
emails: z.string().array(),
|
||||||
publicKey: z.unknown(),
|
publicKey: z.unknown(),
|
||||||
|
publicKeyThumbprint: z.string(),
|
||||||
alg: z.string(),
|
alg: z.string(),
|
||||||
createdAt: z.date(),
|
createdAt: z.date(),
|
||||||
updatedAt: z.date()
|
updatedAt: z.date()
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ export const PkiAcmeAuthsSchema = z.object({
|
|||||||
id: z.string().uuid(),
|
id: z.string().uuid(),
|
||||||
accountId: z.string().uuid(),
|
accountId: z.string().uuid(),
|
||||||
status: z.string(),
|
status: z.string(),
|
||||||
|
token: z.date().nullable().optional(),
|
||||||
identifierType: z.string(),
|
identifierType: z.string(),
|
||||||
identifierValue: z.string(),
|
identifierValue: z.string(),
|
||||||
expiresAt: z.date(),
|
expiresAt: z.date(),
|
||||||
certificateId: z.string().uuid().nullable().optional(),
|
certificateId: z.string().uuid().nullable().optional(),
|
||||||
createdAt: z.date(),
|
createdAt: z.date(),
|
||||||
updatedAt: z.date(),
|
updatedAt: z.date()
|
||||||
token: z.string().nullable().optional()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TPkiAcmeAuths = z.infer<typeof PkiAcmeAuthsSchema>;
|
export type TPkiAcmeAuths = z.infer<typeof PkiAcmeAuthsSchema>;
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ import { TImmutableDBKeys } from "./models";
|
|||||||
export const PkiAcmeOrdersSchema = z.object({
|
export const PkiAcmeOrdersSchema = z.object({
|
||||||
id: z.string().uuid(),
|
id: z.string().uuid(),
|
||||||
accountId: z.string().uuid(),
|
accountId: z.string().uuid(),
|
||||||
status: z.string(),
|
|
||||||
createdAt: z.date(),
|
|
||||||
updatedAt: z.date(),
|
|
||||||
notBefore: z.date().nullable().optional(),
|
notBefore: z.date().nullable().optional(),
|
||||||
notAfter: 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>;
|
export type TPkiAcmeOrders = z.infer<typeof PkiAcmeOrdersSchema>;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { TDbClient } from "@app/db";
|
import { TDbClient } from "@app/db";
|
||||||
import { TableName } from "@app/db/schemas";
|
import { TableName } from "@app/db/schemas";
|
||||||
import { DatabaseError } from "@app/lib/errors";
|
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";
|
import { Knex } from "knex";
|
||||||
|
|
||||||
export type TPkiAcmeChallengeDALFactory = ReturnType<typeof pkiAcmeChallengeDALFactory>;
|
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" });
|
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 {
|
return {
|
||||||
...pkiAcmeChallengeOrm,
|
...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;
|
challengeId: string;
|
||||||
}) => Promise<TAcmeResponse<TRespondToAcmeChallengeResponse>>;
|
}) => Promise<TAcmeResponse<TRespondToAcmeChallengeResponse>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TPkiAcmeChallengeServiceFactory = {};
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ const envSchema = z
|
|||||||
HTTPS_ENABLED: zodStrBool,
|
HTTPS_ENABLED: zodStrBool,
|
||||||
ROTATION_DEVELOPMENT_MODE: zodStrBool.default("false").optional(),
|
ROTATION_DEVELOPMENT_MODE: zodStrBool.default("false").optional(),
|
||||||
DAILY_RESOURCE_CLEAN_UP_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 options
|
||||||
SMTP_HOST: zpStr(z.string().optional()),
|
SMTP_HOST: zpStr(z.string().optional()),
|
||||||
SMTP_IGNORE_TLS: zodStrBool.default("false"),
|
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",
|
(data.NODE_ENV === "development" && data.ROTATION_DEVELOPMENT_MODE) || data.NODE_ENV === "test",
|
||||||
isDailyResourceCleanUpDevelopmentMode:
|
isDailyResourceCleanUpDevelopmentMode:
|
||||||
data.NODE_ENV === "development" && data.DAILY_RESOURCE_CLEAN_UP_DEVELOPMENT_MODE,
|
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,
|
isProductionMode: data.NODE_ENV === "production" || IS_PACKAGED,
|
||||||
isRedisSentinelMode: Boolean(data.REDIS_SENTINEL_HOSTS),
|
isRedisSentinelMode: Boolean(data.REDIS_SENTINEL_HOSTS),
|
||||||
REDIS_SENTINEL_HOSTS: data.REDIS_SENTINEL_HOSTS?.trim()
|
REDIS_SENTINEL_HOSTS: data.REDIS_SENTINEL_HOSTS?.trim()
|
||||||
|
|||||||
Reference in New Issue
Block a user