mirror of
https://github.com/Infisical/infisical.git
synced 2025-03-29 22:02:57 +00:00
Merge pull request #1886 from Infisical/shubham/feat-secret-sharing
This commit is contained in:
2
backend/src/@types/fastify.d.ts
vendored
2
backend/src/@types/fastify.d.ts
vendored
@ -52,6 +52,7 @@ import { TSecretServiceFactory } from "@app/services/secret/secret-service";
|
||||
import { TSecretBlindIndexServiceFactory } from "@app/services/secret-blind-index/secret-blind-index-service";
|
||||
import { TSecretFolderServiceFactory } from "@app/services/secret-folder/secret-folder-service";
|
||||
import { TSecretImportServiceFactory } from "@app/services/secret-import/secret-import-service";
|
||||
import { TSecretSharingServiceFactory } from "@app/services/secret-sharing/secret-sharing-service";
|
||||
import { TSecretTagServiceFactory } from "@app/services/secret-tag/secret-tag-service";
|
||||
import { TServiceTokenServiceFactory } from "@app/services/service-token/service-token-service";
|
||||
import { TSuperAdminServiceFactory } from "@app/services/super-admin/super-admin-service";
|
||||
@ -143,6 +144,7 @@ declare module "fastify" {
|
||||
dynamicSecretLease: TDynamicSecretLeaseServiceFactory;
|
||||
projectUserAdditionalPrivilege: TProjectUserAdditionalPrivilegeServiceFactory;
|
||||
identityProjectAdditionalPrivilege: TIdentityProjectAdditionalPrivilegeServiceFactory;
|
||||
secretSharing: TSecretSharingServiceFactory;
|
||||
};
|
||||
// this is exclusive use for middlewares in which we need to inject data
|
||||
// everywhere else access using service layer
|
||||
|
4
backend/src/@types/knex.d.ts
vendored
4
backend/src/@types/knex.d.ts
vendored
@ -186,6 +186,9 @@ import {
|
||||
TSecretScanningGitRisks,
|
||||
TSecretScanningGitRisksInsert,
|
||||
TSecretScanningGitRisksUpdate,
|
||||
TSecretSharing,
|
||||
TSecretSharingInsert,
|
||||
TSecretSharingUpdate,
|
||||
TSecretsInsert,
|
||||
TSecretSnapshotFolders,
|
||||
TSecretSnapshotFoldersInsert,
|
||||
@ -328,6 +331,7 @@ declare module "knex/types/tables" {
|
||||
TSecretFolderVersionsInsert,
|
||||
TSecretFolderVersionsUpdate
|
||||
>;
|
||||
[TableName.SecretSharing]: Knex.CompositeTableType<TSecretSharing, TSecretSharingInsert, TSecretSharingUpdate>;
|
||||
[TableName.SecretTag]: Knex.CompositeTableType<TSecretTags, TSecretTagsInsert, TSecretTagsUpdate>;
|
||||
[TableName.SecretImport]: Knex.CompositeTableType<TSecretImports, TSecretImportsInsert, TSecretImportsUpdate>;
|
||||
[TableName.Integration]: Knex.CompositeTableType<TIntegrations, TIntegrationsInsert, TIntegrationsUpdate>;
|
||||
|
29
backend/src/db/migrations/20240528190137_secret_sharing.ts
Normal file
29
backend/src/db/migrations/20240528190137_secret_sharing.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
import { createOnUpdateTrigger } from "../utils";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
if (!(await knex.schema.hasTable(TableName.SecretSharing))) {
|
||||
await knex.schema.createTable(TableName.SecretSharing, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
t.string("name").notNullable();
|
||||
t.text("encryptedValue").notNullable();
|
||||
t.text("iv").notNullable();
|
||||
t.text("tag").notNullable();
|
||||
t.text("hashedHex").notNullable();
|
||||
t.timestamp("expiresAt").notNullable();
|
||||
t.uuid("userId").notNullable();
|
||||
t.uuid("orgId").notNullable();
|
||||
t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE");
|
||||
t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE");
|
||||
t.timestamps(true, true, true);
|
||||
});
|
||||
|
||||
await createOnUpdateTrigger(knex, TableName.SecretSharing);
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.dropTableIfExists(TableName.SecretSharing);
|
||||
}
|
@ -60,6 +60,7 @@ export * from "./secret-imports";
|
||||
export * from "./secret-rotation-outputs";
|
||||
export * from "./secret-rotations";
|
||||
export * from "./secret-scanning-git-risks";
|
||||
export * from "./secret-sharing";
|
||||
export * from "./secret-snapshot-folders";
|
||||
export * from "./secret-snapshot-secrets";
|
||||
export * from "./secret-snapshots";
|
||||
|
@ -29,6 +29,7 @@ export enum TableName {
|
||||
ProjectKeys = "project_keys",
|
||||
Secret = "secrets",
|
||||
SecretReference = "secret_references",
|
||||
SecretSharing = "secret_sharing",
|
||||
SecretBlindIndex = "secret_blind_indexes",
|
||||
SecretVersion = "secret_versions",
|
||||
SecretFolder = "secret_folders",
|
||||
|
26
backend/src/db/schemas/secret-sharing.ts
Normal file
26
backend/src/db/schemas/secret-sharing.ts
Normal file
@ -0,0 +1,26 @@
|
||||
// Code generated by automation script, DO NOT EDIT.
|
||||
// Automated by pulling database and generating zod schema
|
||||
// To update. Just run npm run generate:schema
|
||||
// Written by akhilmhdh.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const SecretSharingSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string(),
|
||||
encryptedValue: z.string(),
|
||||
iv: z.string(),
|
||||
tag: z.string(),
|
||||
hashedHex: z.string(),
|
||||
expiresAt: z.date(),
|
||||
userId: z.string().uuid(),
|
||||
orgId: z.string().uuid(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
});
|
||||
|
||||
export type TSecretSharing = z.infer<typeof SecretSharingSchema>;
|
||||
export type TSecretSharingInsert = Omit<z.input<typeof SecretSharingSchema>, TImmutableDBKeys>;
|
||||
export type TSecretSharingUpdate = Partial<Omit<z.input<typeof SecretSharingSchema>, TImmutableDBKeys>>;
|
@ -66,3 +66,11 @@ export const creationLimit: RateLimitOptions = {
|
||||
max: 30,
|
||||
keyGenerator: (req) => req.realIp
|
||||
};
|
||||
|
||||
// Public endpoints to avoid brute force attacks
|
||||
export const publicEndpointLimit: RateLimitOptions = {
|
||||
// Shared Secrets
|
||||
timeWindow: 60 * 1000,
|
||||
max: 30,
|
||||
keyGenerator: (req) => req.realIp
|
||||
};
|
||||
|
@ -130,6 +130,8 @@ import { secretFolderServiceFactory } from "@app/services/secret-folder/secret-f
|
||||
import { secretFolderVersionDALFactory } from "@app/services/secret-folder/secret-folder-version-dal";
|
||||
import { secretImportDALFactory } from "@app/services/secret-import/secret-import-dal";
|
||||
import { secretImportServiceFactory } from "@app/services/secret-import/secret-import-service";
|
||||
import { secretSharingDALFactory } from "@app/services/secret-sharing/secret-sharing-dal";
|
||||
import { secretSharingServiceFactory } from "@app/services/secret-sharing/secret-sharing-service";
|
||||
import { secretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal";
|
||||
import { secretTagServiceFactory } from "@app/services/secret-tag/secret-tag-service";
|
||||
import { serviceTokenDALFactory } from "@app/services/service-token/service-token-dal";
|
||||
@ -253,6 +255,7 @@ export const registerRoutes = async (
|
||||
const groupProjectMembershipRoleDAL = groupProjectMembershipRoleDALFactory(db);
|
||||
const userGroupMembershipDAL = userGroupMembershipDALFactory(db);
|
||||
const secretScanningDAL = secretScanningDALFactory(db);
|
||||
const secretSharingDAL = secretSharingDALFactory(db);
|
||||
const licenseDAL = licenseDALFactory(db);
|
||||
const dynamicSecretDAL = dynamicSecretDALFactory(db);
|
||||
const dynamicSecretLeaseDAL = dynamicSecretLeaseDALFactory(db);
|
||||
@ -612,6 +615,12 @@ export const registerRoutes = async (
|
||||
projectEnvDAL,
|
||||
projectBotService
|
||||
});
|
||||
|
||||
const secretSharingService = secretSharingServiceFactory({
|
||||
permissionService,
|
||||
secretSharingDAL
|
||||
});
|
||||
|
||||
const sarService = secretApprovalRequestServiceFactory({
|
||||
permissionService,
|
||||
projectBotService,
|
||||
@ -785,7 +794,8 @@ export const registerRoutes = async (
|
||||
const dailyResourceCleanUp = dailyResourceCleanUpQueueServiceFactory({
|
||||
auditLogDAL,
|
||||
queueService,
|
||||
identityAccessTokenDAL
|
||||
identityAccessTokenDAL,
|
||||
secretSharingDAL
|
||||
});
|
||||
|
||||
await superAdminService.initServerCfg();
|
||||
@ -851,7 +861,8 @@ export const registerRoutes = async (
|
||||
secretBlindIndex: secretBlindIndexService,
|
||||
telemetry: telemetryService,
|
||||
projectUserAdditionalPrivilege: projectUserAdditionalPrivilegeService,
|
||||
identityProjectAdditionalPrivilege: identityProjectAdditionalPrivilegeService
|
||||
identityProjectAdditionalPrivilege: identityProjectAdditionalPrivilegeService,
|
||||
secretSharing: secretSharingService
|
||||
});
|
||||
|
||||
server.decorate<FastifyZodProvider["store"]>("store", {
|
||||
|
@ -19,6 +19,7 @@ import { registerProjectMembershipRouter } from "./project-membership-router";
|
||||
import { registerProjectRouter } from "./project-router";
|
||||
import { registerSecretFolderRouter } from "./secret-folder-router";
|
||||
import { registerSecretImportRouter } from "./secret-import-router";
|
||||
import { registerSecretSharingRouter } from "./secret-sharing-router";
|
||||
import { registerSecretTagRouter } from "./secret-tag-router";
|
||||
import { registerSsoRouter } from "./sso-router";
|
||||
import { registerUserActionRouter } from "./user-action-router";
|
||||
@ -65,4 +66,5 @@ export const registerV1Routes = async (server: FastifyZodProvider) => {
|
||||
await server.register(registerIntegrationAuthRouter, { prefix: "/integration-auth" });
|
||||
await server.register(registerWebhookRouter, { prefix: "/webhooks" });
|
||||
await server.register(registerIdentityRouter, { prefix: "/identities" });
|
||||
await server.register(registerSecretSharingRouter, { prefix: "/secret-sharing" });
|
||||
};
|
||||
|
139
backend/src/server/routes/v1/secret-sharing-router.ts
Normal file
139
backend/src/server/routes/v1/secret-sharing-router.ts
Normal file
@ -0,0 +1,139 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { SecretSharingSchema } from "@app/db/schemas";
|
||||
import { publicEndpointLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
|
||||
export const registerSecretSharingRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
response: {
|
||||
200: z.array(SecretSharingSchema)
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const sharedSecrets = await req.server.services.secretSharing.getSharedSecrets({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
orgId: req.permission.orgId,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId
|
||||
});
|
||||
|
||||
return sharedSecrets;
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/public/:id",
|
||||
config: {
|
||||
rateLimit: publicEndpointLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
id: z.string().uuid()
|
||||
}),
|
||||
querystring: z.object({
|
||||
hashedHex: z.string()
|
||||
}),
|
||||
response: {
|
||||
200: SecretSharingSchema.pick({ name: true, encryptedValue: true, iv: true, tag: true, expiresAt: true })
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const sharedSecret = await req.server.services.secretSharing.getActiveSharedSecretByIdAndHashedHex(
|
||||
req.params.id,
|
||||
req.query.hashedHex
|
||||
);
|
||||
if (!sharedSecret) return undefined;
|
||||
return {
|
||||
name: sharedSecret.name,
|
||||
encryptedValue: sharedSecret.encryptedValue,
|
||||
iv: sharedSecret.iv,
|
||||
tag: sharedSecret.tag,
|
||||
expiresAt: sharedSecret.expiresAt
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
body: z.object({
|
||||
name: z.string(),
|
||||
encryptedValue: z.string(),
|
||||
iv: z.string(),
|
||||
tag: z.string(),
|
||||
hashedHex: z.string(),
|
||||
expiresAt: z.string().refine((date) => new Date(date) > new Date(), {
|
||||
message: "Expires at should be a future date"
|
||||
})
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
id: z.string().uuid()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const { name, encryptedValue, iv, tag, hashedHex, expiresAt } = req.body;
|
||||
const sharedSecret = await req.server.services.secretSharing.createSharedSecret({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
orgId: req.permission.orgId,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
name,
|
||||
encryptedValue,
|
||||
iv,
|
||||
tag,
|
||||
hashedHex,
|
||||
expiresAt: new Date(expiresAt)
|
||||
});
|
||||
return { id: sharedSecret.id };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "DELETE",
|
||||
url: "/:sharedSecretId",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
sharedSecretId: z.string().uuid()
|
||||
}),
|
||||
response: {
|
||||
200: SecretSharingSchema
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const { sharedSecretId } = req.params;
|
||||
const deletedSharedSecret = await req.server.services.secretSharing.deleteSharedSecretById({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
orgId: req.permission.orgId,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
sharedSecretId
|
||||
});
|
||||
|
||||
return { ...deletedSharedSecret };
|
||||
}
|
||||
});
|
||||
};
|
@ -3,10 +3,12 @@ import { logger } from "@app/lib/logger";
|
||||
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
|
||||
|
||||
import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal";
|
||||
import { TSecretSharingDALFactory } from "../secret-sharing/secret-sharing-dal";
|
||||
|
||||
type TDailyResourceCleanUpQueueServiceFactoryDep = {
|
||||
auditLogDAL: Pick<TAuditLogDALFactory, "pruneAuditLog">;
|
||||
identityAccessTokenDAL: Pick<TIdentityAccessTokenDALFactory, "removeExpiredTokens">;
|
||||
secretSharingDAL: Pick<TSecretSharingDALFactory, "pruneExpiredSharedSecrets">;
|
||||
queueService: TQueueServiceFactory;
|
||||
};
|
||||
|
||||
@ -15,12 +17,14 @@ export type TDailyResourceCleanUpQueueServiceFactory = ReturnType<typeof dailyRe
|
||||
export const dailyResourceCleanUpQueueServiceFactory = ({
|
||||
auditLogDAL,
|
||||
queueService,
|
||||
identityAccessTokenDAL
|
||||
identityAccessTokenDAL,
|
||||
secretSharingDAL
|
||||
}: TDailyResourceCleanUpQueueServiceFactoryDep) => {
|
||||
queueService.start(QueueName.DailyResourceCleanUp, async () => {
|
||||
logger.info(`${QueueName.DailyResourceCleanUp}: queue task started`);
|
||||
await auditLogDAL.pruneAuditLog();
|
||||
await identityAccessTokenDAL.removeExpiredTokens();
|
||||
await secretSharingDAL.pruneExpiredSharedSecrets();
|
||||
logger.info(`${QueueName.DailyResourceCleanUp}: queue task completed`);
|
||||
});
|
||||
|
||||
|
27
backend/src/services/secret-sharing/secret-sharing-dal.ts
Normal file
27
backend/src/services/secret-sharing/secret-sharing-dal.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { DatabaseError } from "@app/lib/errors";
|
||||
import { ormify } from "@app/lib/knex";
|
||||
|
||||
export type TSecretSharingDALFactory = ReturnType<typeof secretSharingDALFactory>;
|
||||
|
||||
export const secretSharingDALFactory = (db: TDbClient) => {
|
||||
const sharedSecretOrm = ormify(db, TableName.SecretSharing);
|
||||
|
||||
const pruneExpiredSharedSecrets = async (tx?: Knex) => {
|
||||
try {
|
||||
const today = new Date();
|
||||
const docs = await (tx || db)(TableName.SecretSharing).where("expiresAt", "<", today).del();
|
||||
return docs;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "pruneExpiredSharedSecrets" });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
...sharedSecretOrm,
|
||||
pruneExpiredSharedSecrets
|
||||
};
|
||||
};
|
@ -0,0 +1,66 @@
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import { UnauthorizedError } from "@app/lib/errors";
|
||||
|
||||
import { TSecretSharingDALFactory } from "./secret-sharing-dal";
|
||||
import { TCreateSharedSecretDTO, TDeleteSharedSecretDTO, TSharedSecretPermission } from "./secret-sharing-types";
|
||||
|
||||
type TSecretSharingServiceFactoryDep = {
|
||||
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
|
||||
secretSharingDAL: TSecretSharingDALFactory;
|
||||
};
|
||||
|
||||
export type TSecretSharingServiceFactory = ReturnType<typeof secretSharingServiceFactory>;
|
||||
|
||||
export const secretSharingServiceFactory = ({
|
||||
permissionService,
|
||||
secretSharingDAL
|
||||
}: TSecretSharingServiceFactoryDep) => {
|
||||
const createSharedSecret = async (createSharedSecretInput: TCreateSharedSecretDTO) => {
|
||||
const { actor, actorId, orgId, actorAuthMethod, actorOrgId, name, encryptedValue, iv, tag, hashedHex, expiresAt } =
|
||||
createSharedSecretInput;
|
||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
|
||||
if (!permission) throw new UnauthorizedError({ name: "User not in org" });
|
||||
const newSharedSecret = await secretSharingDAL.create({
|
||||
name,
|
||||
encryptedValue,
|
||||
iv,
|
||||
tag,
|
||||
hashedHex,
|
||||
expiresAt,
|
||||
userId: actorId,
|
||||
orgId
|
||||
});
|
||||
return { id: newSharedSecret.id };
|
||||
};
|
||||
|
||||
const getSharedSecrets = async (getSharedSecretsInput: TSharedSecretPermission) => {
|
||||
const { actor, actorId, orgId, actorAuthMethod, actorOrgId } = getSharedSecretsInput;
|
||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
|
||||
if (!permission) throw new UnauthorizedError({ name: "User not in org" });
|
||||
const userSharedSecrets = await secretSharingDAL.find({ userId: actorId, orgId }, { sort: [["expiresAt", "asc"]] });
|
||||
return userSharedSecrets;
|
||||
};
|
||||
|
||||
const getActiveSharedSecretByIdAndHashedHex = async (sharedSecretId: string, hashedHex: string) => {
|
||||
const sharedSecret = await secretSharingDAL.findOne({ id: sharedSecretId, hashedHex });
|
||||
if (sharedSecret && sharedSecret.expiresAt < new Date()) {
|
||||
return;
|
||||
}
|
||||
return sharedSecret;
|
||||
};
|
||||
|
||||
const deleteSharedSecretById = async (deleteSharedSecretInput: TDeleteSharedSecretDTO) => {
|
||||
const { actor, actorId, orgId, actorAuthMethod, actorOrgId, sharedSecretId } = deleteSharedSecretInput;
|
||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
|
||||
if (!permission) throw new UnauthorizedError({ name: "User not in org" });
|
||||
const deletedSharedSecret = await secretSharingDAL.deleteById(sharedSecretId);
|
||||
return deletedSharedSecret;
|
||||
};
|
||||
|
||||
return {
|
||||
createSharedSecret,
|
||||
getSharedSecrets,
|
||||
deleteSharedSecretById,
|
||||
getActiveSharedSecretByIdAndHashedHex
|
||||
};
|
||||
};
|
22
backend/src/services/secret-sharing/secret-sharing-types.ts
Normal file
22
backend/src/services/secret-sharing/secret-sharing-types.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { ActorAuthMethod, ActorType } from "../auth/auth-type";
|
||||
|
||||
export type TSharedSecretPermission = {
|
||||
actor: ActorType;
|
||||
actorId: string;
|
||||
actorAuthMethod: ActorAuthMethod;
|
||||
actorOrgId: string;
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export type TCreateSharedSecretDTO = {
|
||||
name: string;
|
||||
encryptedValue: string;
|
||||
iv: string;
|
||||
tag: string;
|
||||
hashedHex: string;
|
||||
expiresAt: Date;
|
||||
} & TSharedSecretPermission;
|
||||
|
||||
export type TDeleteSharedSecretDTO = {
|
||||
sharedSecretId: string;
|
||||
} & TSharedSecretPermission;
|
44
docs/documentation/platform/secret-sharing.mdx
Normal file
44
docs/documentation/platform/secret-sharing.mdx
Normal file
@ -0,0 +1,44 @@
|
||||
---
|
||||
title: "Secret Sharing"
|
||||
sidebarTitle: "Secret Sharing"
|
||||
description: "Learn how to share time-bound secrets securely with anybody on the internet."
|
||||
---
|
||||
|
||||
Developers often need to share secrets with their team members, contractors, or other third parties. This can be a risky process, as secrets can be easily leaked or misused. Infisical provides a secure way to share secrets with anybody on the internet in a time-bound manner.
|
||||
|
||||
## Share a Secret
|
||||
|
||||
1. Navigate to the **Projects** page.
|
||||
2. Click on the **Secret Sharing** tab from the sidebar.
|
||||
|
||||

|
||||
|
||||
3. Click on the **Share Secret** button.
|
||||
|
||||
<Note>
|
||||
Infisical does not store the secret you share. This is a part of our Zero
|
||||
Knowledge Architecture.
|
||||
</Note>
|
||||
|
||||
4. Enter the secret you want to share and set the expiration time. Click on the **Share Secret** button.
|
||||
|
||||

|
||||
|
||||
<Note>
|
||||
Secret once set cannot be changed. This is to ensure that the secret is not
|
||||
tampered with.
|
||||
</Note>
|
||||
|
||||
5. Copy the link and share it with the intended recipient. Anybody with the link can access the secret before its expiration time. Hence, it is recommended to share the link only with the intended recipient.
|
||||
|
||||

|
||||
|
||||
## Access a Shared Secret
|
||||
|
||||
Just click on the link you received to access the secret. The secret will be displayed on the screen & for how long it is valid.
|
||||
|
||||

|
||||
|
||||
## Delete a Shared Secret
|
||||
|
||||
In the **Secret Sharing** tab, click on the **Delete** button next to the secret you want to delete. This will delete the secret immediately & the link will no longer be accessible.
|
BIN
docs/images/platform/secret-sharing/copy-url.png
Normal file
BIN
docs/images/platform/secret-sharing/copy-url.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 54 KiB |
BIN
docs/images/platform/secret-sharing/new-secret.png
Normal file
BIN
docs/images/platform/secret-sharing/new-secret.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 45 KiB |
BIN
docs/images/platform/secret-sharing/overview.png
Normal file
BIN
docs/images/platform/secret-sharing/overview.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 157 KiB |
BIN
docs/images/platform/secret-sharing/public-view.png
Normal file
BIN
docs/images/platform/secret-sharing/public-view.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 221 KiB |
@ -137,6 +137,7 @@
|
||||
"documentation/platform/secret-rotation/aws-iam"
|
||||
]
|
||||
},
|
||||
"documentation/platform/secret-sharing",
|
||||
{
|
||||
"group": "Dynamic Secrets",
|
||||
"pages": [
|
||||
|
@ -23,7 +23,8 @@ export const publicPaths = [
|
||||
"/login/provider/success", // TODO: change
|
||||
"/login/provider/error", // TODO: change
|
||||
"/login/sso",
|
||||
"/admin/signup"
|
||||
"/admin/signup",
|
||||
"/shared/secret/[id]"
|
||||
];
|
||||
|
||||
export const languageMap = {
|
||||
|
3
frontend/src/hooks/api/secretSharing/index.ts
Normal file
3
frontend/src/hooks/api/secretSharing/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./mutations";
|
||||
export * from "./queries";
|
||||
export * from "./types";
|
35
frontend/src/hooks/api/secretSharing/mutations.ts
Normal file
35
frontend/src/hooks/api/secretSharing/mutations.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { TCreateSharedSecretRequest, TDeleteSharedSecretRequest, TSharedSecret } from "./types";
|
||||
|
||||
export const useCreateSharedSecret = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (inputData: TCreateSharedSecretRequest) => {
|
||||
const { data } = await apiRequest.post<TSharedSecret>("/api/v1/secret-sharing", inputData);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => queryClient.invalidateQueries(["sharedSecrets"])
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteSharedSecret = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<
|
||||
TSharedSecret,
|
||||
{ message: string },
|
||||
{ sharedSecretId: string }
|
||||
>({
|
||||
mutationFn: async ({ sharedSecretId }: TDeleteSharedSecretRequest) => {
|
||||
const { data } = await apiRequest.delete<TSharedSecret>(
|
||||
`/api/v1/secret-sharing/${sharedSecretId}`
|
||||
);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(["sharedSecrets"]);
|
||||
}
|
||||
});
|
||||
};
|
34
frontend/src/hooks/api/secretSharing/queries.ts
Normal file
34
frontend/src/hooks/api/secretSharing/queries.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { TSharedSecret, TViewSharedSecretResponse } from "./types";
|
||||
|
||||
export const useGetSharedSecrets = () => {
|
||||
return useQuery({
|
||||
queryKey: ["sharedSecrets"],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<TSharedSecret[]>(
|
||||
"/api/v1/secret-sharing/"
|
||||
);
|
||||
return data;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetActiveSharedSecretByIdAndHashedHex = (id: string, hashedHex: string) => {
|
||||
return useQuery<TViewSharedSecretResponse, [string]>({
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<TViewSharedSecretResponse>(
|
||||
`/api/v1/secret-sharing/public/${id}?hashedHex=${hashedHex}`
|
||||
);
|
||||
return {
|
||||
name: data.name,
|
||||
encryptedValue: data.encryptedValue,
|
||||
iv: data.iv,
|
||||
tag: data.tag,
|
||||
expiresAt: data.expiresAt
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
33
frontend/src/hooks/api/secretSharing/types.ts
Normal file
33
frontend/src/hooks/api/secretSharing/types.ts
Normal file
@ -0,0 +1,33 @@
|
||||
export type TSharedSecret = {
|
||||
id: string;
|
||||
name: string;
|
||||
encryptedValue: string;
|
||||
iv: string;
|
||||
tag: string;
|
||||
hashedHex: string;
|
||||
userId: string;
|
||||
expiresAt: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export type TCreateSharedSecretRequest = {
|
||||
name: string;
|
||||
encryptedValue: string;
|
||||
iv: string;
|
||||
tag: string;
|
||||
hashedHex: string;
|
||||
expiresAt: Date;
|
||||
};
|
||||
|
||||
export type TViewSharedSecretResponse = {
|
||||
name: string;
|
||||
encryptedValue: string;
|
||||
iv: string;
|
||||
tag: string;
|
||||
expiresAt: Date;
|
||||
};
|
||||
|
||||
export type TDeleteSharedSecretRequest = {
|
||||
sharedSecretId: string;
|
||||
};
|
@ -630,6 +630,18 @@ export const AppLayout = ({ children }: LayoutProps) => {
|
||||
</MenuItem>
|
||||
</a>
|
||||
</Link>
|
||||
<Link href={`/org/${currentOrg?.id}/secret-sharing`} passHref>
|
||||
<a>
|
||||
<MenuItem
|
||||
isSelected={
|
||||
router.asPath === `/org/${currentOrg?.id}/secret-sharing`
|
||||
}
|
||||
icon="system-outline-90-lock-closed"
|
||||
>
|
||||
Secret Sharing
|
||||
</MenuItem>
|
||||
</a>
|
||||
</Link>
|
||||
{(window.location.origin.includes("https://app.infisical.com") ||
|
||||
window.location.origin.includes("https://gamma.infisical.com")) && (
|
||||
<Link href={`/org/${currentOrg?.id}/billing`} passHref>
|
||||
|
27
frontend/src/pages/org/[id]/secret-sharing/index.tsx
Normal file
27
frontend/src/pages/org/[id]/secret-sharing/index.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Head from "next/head";
|
||||
|
||||
import { ShareSecretPage } from "@app/views/ShareSecretPage";
|
||||
|
||||
const SecretApproval = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{t("common.head-title", { title: t("approval.title") })}</title>
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
<meta property="og:image" content="/images/message.png" />
|
||||
<meta property="og:title" content={String(t("approval.og-title"))} />
|
||||
<meta name="og:description" content={String(t("approval.og-description"))} />
|
||||
</Head>
|
||||
<div className="h-full">
|
||||
<ShareSecretPage />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SecretApproval;
|
||||
|
||||
SecretApproval.requireAuth = true;
|
24
frontend/src/pages/shared/secret/[id]/index.tsx
Normal file
24
frontend/src/pages/shared/secret/[id]/index.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import Head from "next/head";
|
||||
|
||||
import { ShareSecretPublicPage } from "@app/views/ShareSecretPublicPage";
|
||||
|
||||
const SecretApproval = () => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Securely Share Secrets | Infisical</title>
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
<meta property="og:image" content="/images/message.png" />
|
||||
<meta property="og:title" content="" />
|
||||
<meta name="og:description" content="" />
|
||||
</Head>
|
||||
<div className="h-full">
|
||||
<ShareSecretPublicPage />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SecretApproval;
|
||||
|
||||
SecretApproval.requireAuth = false;
|
30
frontend/src/views/ShareSecretPage/ShareSecretPage.tsx
Normal file
30
frontend/src/views/ShareSecretPage/ShareSecretPage.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import Link from "next/link";
|
||||
import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { ShareSecretSection } from "./components";
|
||||
|
||||
export const ShareSecretPage = () => {
|
||||
return (
|
||||
<div className="container mx-auto h-full w-full max-w-7xl bg-bunker-800 px-6 text-white">
|
||||
<div className="flex items-center justify-between py-6">
|
||||
<div className="flex w-full flex-col">
|
||||
<h2 className="text-3xl font-semibold text-gray-200">Secret Sharing</h2>
|
||||
<p className="text-bunker-300">Share secrets with anybody securely and efficiently</p>
|
||||
</div>
|
||||
<div className="flex w-max justify-center">
|
||||
<Link href="https://infisical.com/docs/documentation/platform/secret-sharing">
|
||||
<span className="w-max cursor-pointer rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white">
|
||||
Documentation{" "}
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowUpRightFromSquare}
|
||||
className="mb-[0.06rem] ml-1 text-xs"
|
||||
/>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<ShareSecretSection />
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,289 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import { AxiosError } from "axios";
|
||||
import * as yup from "yup";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
encryptSymmetric,
|
||||
} from "@app/components/utilities/cryptography/crypto";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
SecretInput,
|
||||
Select,
|
||||
SelectItem
|
||||
} from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import { useTimedReset } from "@app/hooks";
|
||||
import { useCreateSharedSecret } from "@app/hooks/api/secretSharing";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const expirationUnitsAndActions = [
|
||||
{
|
||||
unit: "Minutes",
|
||||
action: (expiresAt: Date, expiresInValue: number) =>
|
||||
expiresAt.setMinutes(expiresAt.getMinutes() + expiresInValue)
|
||||
},
|
||||
{
|
||||
unit: "Hours",
|
||||
action: (expiresAt: Date, expiresInValue: number) =>
|
||||
expiresAt.setHours(expiresAt.getHours() + expiresInValue)
|
||||
},
|
||||
{
|
||||
unit: "Days",
|
||||
action: (expiresAt: Date, expiresInValue: number) =>
|
||||
expiresAt.setDate(expiresAt.getDate() + expiresInValue)
|
||||
},
|
||||
{
|
||||
unit: "Weeks",
|
||||
action: (expiresAt: Date, expiresInValue: number) =>
|
||||
expiresAt.setDate(expiresAt.getDate() + expiresInValue * 7)
|
||||
},
|
||||
{
|
||||
unit: "Months",
|
||||
action: (expiresAt: Date, expiresInValue: number) =>
|
||||
expiresAt.setMonth(expiresAt.getMonth() + expiresInValue)
|
||||
},
|
||||
{
|
||||
unit: "Years",
|
||||
action: (expiresAt: Date, expiresInValue: number) =>
|
||||
expiresAt.setFullYear(expiresAt.getFullYear() + expiresInValue)
|
||||
}
|
||||
];
|
||||
|
||||
const schema = yup.object({
|
||||
name: yup.string().max(100).required().label("Shared Secret Name"),
|
||||
value: yup.string().max(1000).required().label("Shared Secret Value"),
|
||||
expiresInValue: yup.number().min(1).required().label("Expiration Value"),
|
||||
expiresInUnit: yup.string().required().label("Expiration Unit")
|
||||
});
|
||||
|
||||
export type FormData = yup.InferType<typeof schema>;
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["createSharedSecret"]>;
|
||||
handlePopUpToggle: (
|
||||
popUpName: keyof UsePopUpState<["createSharedSecret"]>,
|
||||
state?: boolean
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
const {
|
||||
control,
|
||||
reset,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<FormData>({
|
||||
resolver: yupResolver(schema)
|
||||
});
|
||||
const createSharedSecret = useCreateSharedSecret();
|
||||
const { currentOrg } = useOrganization();
|
||||
const [newSharedSecret, setnewSharedSecret] = useState("");
|
||||
const hasSharedSecret = Boolean(newSharedSecret);
|
||||
const [isUrlCopied, , setIsUrlCopied] = useTimedReset<boolean>({
|
||||
initialState: false,
|
||||
});
|
||||
|
||||
const copyUrlToClipboard = () => {
|
||||
navigator.clipboard.writeText(newSharedSecret);
|
||||
setIsUrlCopied(true);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (isUrlCopied) {
|
||||
setTimeout(() => setIsUrlCopied(false), 2000);
|
||||
}
|
||||
}, [isUrlCopied]);
|
||||
|
||||
const onFormSubmit = async ({ name, value, expiresInValue, expiresInUnit }: FormData) => {
|
||||
try {
|
||||
if (!currentOrg?.id) return;
|
||||
|
||||
const key = crypto.randomBytes(16).toString("hex");
|
||||
const hashedHex = crypto.createHash("sha256").update(key).digest("hex");
|
||||
const { ciphertext, iv, tag } = encryptSymmetric({
|
||||
plaintext: value,
|
||||
key
|
||||
});
|
||||
|
||||
|
||||
const expiresAt = new Date();
|
||||
const updateExpiresAt = expirationUnitsAndActions.find(
|
||||
(item) => item.unit === expiresInUnit
|
||||
)?.action;
|
||||
if (updateExpiresAt) {
|
||||
updateExpiresAt(expiresAt, expiresInValue);
|
||||
}
|
||||
|
||||
const { id } = await createSharedSecret.mutateAsync({
|
||||
name,
|
||||
encryptedValue: ciphertext,
|
||||
iv,
|
||||
tag,
|
||||
hashedHex,
|
||||
expiresAt,
|
||||
});
|
||||
setnewSharedSecret(
|
||||
`${window.location.origin}/shared/secret/${id}?key=${encodeURIComponent(hashedHex)}-${encodeURIComponent(key)}`
|
||||
);
|
||||
|
||||
createNotification({
|
||||
text: "Successfully created a shared secret",
|
||||
type: "success"
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const axiosError = err as AxiosError;
|
||||
if (axiosError?.response?.status === 401) {
|
||||
createNotification({
|
||||
text: "You do not have access to create shared secrets",
|
||||
type: "error"
|
||||
});
|
||||
} else {
|
||||
createNotification({
|
||||
text: "Failed to create a shared secret",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.createSharedSecret?.isOpen}
|
||||
onOpenChange={(open) => {
|
||||
handlePopUpToggle("createSharedSecret", open);
|
||||
reset();
|
||||
setnewSharedSecret("");
|
||||
}}
|
||||
>
|
||||
<ModalContent
|
||||
title="Share a secret with anybody on the internet"
|
||||
subTitle="When a secret is shared, you will only see the public share URL once before it disappears. Make sure to save it somewhere."
|
||||
>
|
||||
{!hasSharedSecret ? (
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Shared Secret Name"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="Type your secret identifier" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="value"
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Shared Secret Value"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<SecretInput
|
||||
isVisible
|
||||
{...field}
|
||||
containerClassName="py-1.5 rounded-md transition-all group-hover:mr-2 text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="flex w-full flex-row justify-end">
|
||||
<div className="w-3/5">
|
||||
<Controller
|
||||
control={control}
|
||||
name="expiresInValue"
|
||||
defaultValue={1}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Expiration Value"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} type="number" min={0} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-2/5 pl-4">
|
||||
<Controller
|
||||
control={control}
|
||||
name="expiresInUnit"
|
||||
defaultValue={expirationUnitsAndActions[0].unit}
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Expiration Unit"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{expirationUnitsAndActions.map(({ unit }) => (
|
||||
<SelectItem value={unit} key={unit}>
|
||||
{unit}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
type="submit"
|
||||
isDisabled={isSubmitting}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button variant="plain" colorSchema="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="mt-2 mb-3 mr-2 flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
|
||||
<p className="mr-4 break-all">{newSharedSecret}</p>
|
||||
<IconButton
|
||||
ariaLabel="copy icon"
|
||||
colorSchema="secondary"
|
||||
className="group relative"
|
||||
onClick={copyUrlToClipboard}
|
||||
>
|
||||
<FontAwesomeIcon icon={isUrlCopied ? faCheck : faCopy} />
|
||||
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
|
||||
Click to Copy
|
||||
</span>
|
||||
</IconButton>
|
||||
</div>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
@ -0,0 +1,84 @@
|
||||
import Head from "next/head";
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, DeleteActionModal } from "@app/components/v2";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useDeleteSharedSecret } from "@app/hooks/api/secretSharing";
|
||||
|
||||
import { AddShareSecretModal } from "./AddShareSecretModal";
|
||||
import { ShareSecretsTable } from "./ShareSecretsTable";
|
||||
|
||||
type DeleteModalData = { name: string; id: string };
|
||||
|
||||
export const ShareSecretSection = () => {
|
||||
const deleteSharedSecret = useDeleteSharedSecret();
|
||||
const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([
|
||||
"createSharedSecret",
|
||||
"deleteSharedSecretConfirmation"
|
||||
] as const);
|
||||
|
||||
const onDeleteApproved = async () => {
|
||||
try {
|
||||
deleteSharedSecret.mutateAsync({
|
||||
sharedSecretId: (popUp?.deleteSharedSecretConfirmation?.data as DeleteModalData)?.id,
|
||||
});
|
||||
createNotification({
|
||||
text: "Successfully deleted shared secret",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
handlePopUpClose("deleteSharedSecretConfirmation");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to delete shared secret",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<Head>
|
||||
<title>Secret Sharing</title>
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
<meta property="og:image" content="/images/message.png" />
|
||||
</Head>
|
||||
<div className="mb-2 flex justify-between">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Shared Secrets</p>
|
||||
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => {
|
||||
handlePopUpOpen("createSharedSecret");
|
||||
}}
|
||||
>
|
||||
Share Secret
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<p className="flex-grow text-gray-400">
|
||||
Every secret shared can be accessed with the URL (shown during creation) before its
|
||||
expiry.
|
||||
</p>
|
||||
</div>
|
||||
<ShareSecretsTable
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
/>
|
||||
<AddShareSecretModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteSharedSecretConfirmation.isOpen}
|
||||
title={`Delete ${(popUp?.deleteSharedSecretConfirmation?.data as DeleteModalData)?.name || " "
|
||||
} shared secret?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteSharedSecretConfirmation", isOpen)}
|
||||
deleteKey={(popUp?.deleteSharedSecretConfirmation?.data as DeleteModalData)?.name}
|
||||
onClose={() => handlePopUpClose("deleteSharedSecretConfirmation")}
|
||||
onDeleteApproved={onDeleteApproved}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,119 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { faTrashCan } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { IconButton, Td, Tr } from "@app/components/v2";
|
||||
import { TSharedSecret } from "@app/hooks/api/secretSharing";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const formatDate = (date: Date): string => (date ? new Date(date).toUTCString() : "");
|
||||
|
||||
const isExpired = (expiresAt: Date): boolean => new Date(expiresAt) < new Date();
|
||||
|
||||
const getValidityStatusText = (expiresAt: Date): string =>
|
||||
isExpired(expiresAt) ? "Expired " : "Valid for ";
|
||||
|
||||
const timeAgo = (inputDate: Date, currentDate: Date): string => {
|
||||
const now = new Date(currentDate).getTime();
|
||||
const date = new Date(inputDate).getTime();
|
||||
const elapsedMilliseconds = now - date;
|
||||
const elapsedSeconds = Math.abs(Math.floor(elapsedMilliseconds / 1000));
|
||||
const elapsedMinutes = Math.abs(Math.floor(elapsedSeconds / 60));
|
||||
const elapsedHours = Math.abs(Math.floor(elapsedMinutes / 60));
|
||||
const elapsedDays = Math.abs(Math.floor(elapsedHours / 24));
|
||||
const elapsedWeeks = Math.abs(Math.floor(elapsedDays / 7));
|
||||
const elapsedMonths = Math.abs(Math.floor(elapsedDays / 30));
|
||||
const elapsedYears = Math.abs(Math.floor(elapsedDays / 365));
|
||||
|
||||
if (elapsedYears > 0) {
|
||||
return `${elapsedYears} year${elapsedYears === 1 ? "" : "s"} ${elapsedMilliseconds >= 0 ? "ago" : "from now"
|
||||
}`;
|
||||
}
|
||||
if (elapsedMonths > 0) {
|
||||
return `${elapsedMonths} month${elapsedMonths === 1 ? "" : "s"} ${elapsedMilliseconds >= 0 ? "ago" : "from now"
|
||||
}`;
|
||||
}
|
||||
if (elapsedWeeks > 0) {
|
||||
return `${elapsedWeeks} week${elapsedWeeks === 1 ? "" : "s"} ${elapsedMilliseconds >= 0 ? "ago" : "from now"
|
||||
}`;
|
||||
}
|
||||
if (elapsedDays > 0) {
|
||||
return `${elapsedDays} day${elapsedDays === 1 ? "" : "s"} ${elapsedMilliseconds >= 0 ? "ago" : "from now"
|
||||
}`;
|
||||
}
|
||||
if (elapsedHours > 0) {
|
||||
return `${elapsedHours} hour${elapsedHours === 1 ? "" : "s"} ${elapsedMilliseconds >= 0 ? "ago" : "from now"
|
||||
}`;
|
||||
}
|
||||
if (elapsedMinutes > 0) {
|
||||
return `${elapsedMinutes} minute${elapsedMinutes === 1 ? "" : "s"} ${elapsedMilliseconds >= 0 ? "ago" : "from now"
|
||||
}`;
|
||||
}
|
||||
return `${elapsedSeconds} second${elapsedSeconds === 1 ? "" : "s"} ${elapsedMilliseconds >= 0 ? "ago" : "from now"
|
||||
}`;
|
||||
};
|
||||
|
||||
export const ShareSecretsRow = ({
|
||||
row,
|
||||
handlePopUpOpen,
|
||||
onSecretExpiration
|
||||
}: {
|
||||
row: TSharedSecret;
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<["deleteSharedSecretConfirmation"]>,
|
||||
{
|
||||
name,
|
||||
id
|
||||
}: {
|
||||
name: string;
|
||||
id: string;
|
||||
}
|
||||
) => void;
|
||||
onSecretExpiration: (expiredSecretId: string) => void;
|
||||
}) => {
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = setInterval(() => {
|
||||
setCurrentTime(new Date());
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpired(row.expiresAt)) {
|
||||
onSecretExpiration(row.id);
|
||||
}
|
||||
}, [isExpired(row.expiresAt)]);
|
||||
|
||||
return (
|
||||
<Tr key={row.id}>
|
||||
<Td>{row.name}</Td>
|
||||
<Td>
|
||||
<p className="text-sm text-yellow-400">{timeAgo(row.createdAt, currentTime)}</p>
|
||||
<p className="text-xs text-gray-500">{formatDate(row.createdAt)}</p>
|
||||
</Td>
|
||||
<Td>
|
||||
<p className={`text-sm ${isExpired(row.expiresAt) ? "text-red-500" : "text-green-500"}`}>
|
||||
{getValidityStatusText(row.expiresAt) + timeAgo(row.expiresAt, currentTime)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">{formatDate(row.expiresAt)}</p>
|
||||
</Td>
|
||||
<Td>
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
handlePopUpOpen("deleteSharedSecretConfirmation", {
|
||||
name: row.name,
|
||||
id: row.id
|
||||
})
|
||||
}
|
||||
colorSchema="danger"
|
||||
ariaLabel="delete"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrashCan} />
|
||||
</IconButton>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
};
|
@ -0,0 +1,72 @@
|
||||
import { faKey } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
import {
|
||||
EmptyState,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { useGetSharedSecrets } from "@app/hooks/api/secretSharing";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
import { ShareSecretsRow } from "./ShareSecretsRow";
|
||||
|
||||
type Props = {
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<["deleteSharedSecretConfirmation"]>,
|
||||
{
|
||||
name,
|
||||
id
|
||||
}: {
|
||||
name: string;
|
||||
id: string;
|
||||
}
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const ShareSecretsTable = ({ handlePopUpOpen }: Props) => {
|
||||
const { isLoading, data = [] } = useGetSharedSecrets();
|
||||
|
||||
let tableData = data.filter((secret) => !secret.expiresAt || new Date(secret.expiresAt) > new Date())
|
||||
const handleSecretExpiration = () => {
|
||||
tableData = data.filter((secret) => !secret.expiresAt || new Date(secret.expiresAt) > new Date());
|
||||
};
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Secret Name</Th> <Th>Created</Th> <Th>Valid Until</Th>
|
||||
<Th aria-label="button" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isLoading && <TableSkeleton columns={4} innerKey="shared-secrets" />}
|
||||
{!isLoading &&
|
||||
tableData &&
|
||||
tableData.map((row) => (
|
||||
<ShareSecretsRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
onSecretExpiration={handleSecretExpiration}
|
||||
/>
|
||||
))}
|
||||
{!isLoading && tableData && tableData?.length === 0 && (
|
||||
<Tr>
|
||||
<Td colSpan={4} className="bg-mineshaft-800 text-center text-bunker-400">
|
||||
<EmptyState title="No secrets shared currently" icon={faKey} />
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
</TBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
};
|
1
frontend/src/views/ShareSecretPage/components/index.tsx
Normal file
1
frontend/src/views/ShareSecretPage/components/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
export { ShareSecretSection } from "./ShareSecretSection";
|
1
frontend/src/views/ShareSecretPage/index.tsx
Normal file
1
frontend/src/views/ShareSecretPage/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
export { ShareSecretPage } from "./ShareSecretPage";
|
@ -0,0 +1,120 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Head from "next/head";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { decryptSymmetric } from "@app/components/utilities/cryptography/crypto";
|
||||
import { useTimedReset } from "@app/hooks";
|
||||
import { useGetActiveSharedSecretByIdAndHashedHex } from "@app/hooks/api/secretSharing";
|
||||
|
||||
import { DragonMainImage, SecretTable } from "./components";
|
||||
|
||||
export const ShareSecretPublicPage = () => {
|
||||
const router = useRouter();
|
||||
const { id, key: urlEncodedPublicKey } = router.query;
|
||||
const [hashedHex, key] = urlEncodedPublicKey!.toString().split("-");
|
||||
|
||||
const publicKey = decodeURIComponent(urlEncodedPublicKey as string);
|
||||
useEffect(() => {
|
||||
if (!id || !publicKey) {
|
||||
router.push("/404");
|
||||
}
|
||||
}, [id, publicKey]);
|
||||
|
||||
const { isLoading, data } = useGetActiveSharedSecretByIdAndHashedHex(id as string, hashedHex as string );
|
||||
const decryptedSecret = useMemo(() => {
|
||||
if (data && data.encryptedValue && publicKey) {
|
||||
const res = decryptSymmetric({
|
||||
ciphertext: data.encryptedValue,
|
||||
iv: data.iv,
|
||||
tag: data.tag,
|
||||
key,
|
||||
});
|
||||
return res;
|
||||
}
|
||||
return "";
|
||||
}, [data, publicKey]);
|
||||
|
||||
const [timeLeft, setTimeLeft] = useState("");
|
||||
const [isUrlCopied, , setIsUrlCopied] = useTimedReset<boolean>({
|
||||
initialState: false,
|
||||
});
|
||||
|
||||
const millisecondsPerDay = 1000 * 60 * 60 * 24;
|
||||
const millisecondsPerHour = 1000 * 60 * 60;
|
||||
const millisecondsPerMinute = 1000 * 60;
|
||||
|
||||
useEffect(() => {
|
||||
const updateTimer = () => {
|
||||
if (data && data.expiresAt) {
|
||||
const expirationTime = new Date(data.expiresAt).getTime();
|
||||
const currentTime = new Date().getTime();
|
||||
const timeDifference = expirationTime - currentTime;
|
||||
|
||||
if (timeDifference < 0) {
|
||||
setTimeLeft("Expired");
|
||||
} else {
|
||||
const hoursRemaining = Math.floor((timeDifference % millisecondsPerDay) / millisecondsPerHour);
|
||||
const minutesRemaining = Math.floor((timeDifference % millisecondsPerHour) / millisecondsPerMinute);
|
||||
const secondsRemaining = Math.floor((timeDifference % millisecondsPerMinute) / 1000);
|
||||
setTimeLeft(`${hoursRemaining}h ${minutesRemaining}m ${secondsRemaining}s`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setInterval(updateTimer, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [data?.expiresAt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isUrlCopied) {
|
||||
setTimeout(() => setIsUrlCopied(false), 2000);
|
||||
}
|
||||
}, [isUrlCopied]);
|
||||
|
||||
|
||||
const copyUrlToClipboard = () => {
|
||||
navigator.clipboard.writeText(decryptedSecret);
|
||||
setIsUrlCopied(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-between bg-bunker-800 text-gray-200 md:h-screen">
|
||||
<Head>
|
||||
<title>Secret Shared | Infisical</title>
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
</Head>
|
||||
|
||||
<div className="my-4 flex justify-center md:my-8">
|
||||
<Image src="/images/biglogo.png" height={180} width={240} alt="Infisical logo" />
|
||||
</div>
|
||||
<p className="mb-6 px-8 text-center text-xl md:px-0 md:text-3xl">
|
||||
You’ve been shared a secret securely with Infisical.
|
||||
</p>
|
||||
<div className="flex min-h-screen w-full flex-col md:flex-row">
|
||||
<DragonMainImage />
|
||||
<div className="m-4 flex flex-1 flex-col items-center justify-start md:m-0">
|
||||
<p className="mt-8 mb-2 text-xl font-semibold text-mineshaft-100 md:mt-20">
|
||||
Secret Details
|
||||
</p>
|
||||
<div className="mb-16 rounded-lg border border-mineshaft-600 bg-mineshaft-900 md:p-8">
|
||||
<SecretTable
|
||||
isLoading={isLoading}
|
||||
sharedSecret={data}
|
||||
decryptedSecret={decryptedSecret}
|
||||
timeLeft={timeLeft}
|
||||
isUrlCopied={isUrlCopied}
|
||||
copyUrlToClipboard={copyUrlToClipboard}
|
||||
/>
|
||||
</div>
|
||||
<Link href="/">
|
||||
<a className="mt-4 cursor-pointer rounded-md bg-mineshaft-500 py-2 px-4 text-lg font-semibold duration-200 hover:bg-primary hover:text-black">
|
||||
Check Infisical out now!
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,14 @@
|
||||
import Image from "next/image";
|
||||
|
||||
export const DragonMainImage = () => {
|
||||
return (
|
||||
<div className="hidden flex-1 flex-col items-center justify-center md:block md:items-start md:p-4">
|
||||
<Image
|
||||
src="/images/dragon-book.svg"
|
||||
height={1000}
|
||||
width={1413}
|
||||
alt="Infisical Dragon - Came to send you a secret!"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,96 @@
|
||||
import { faCheck, faCopy, faKey } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import {
|
||||
EmptyState,
|
||||
IconButton,
|
||||
SecretInput,
|
||||
Table,
|
||||
TableContainer,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { TViewSharedSecretResponse } from "@app/hooks/api/secretSharing";
|
||||
|
||||
type Props = {
|
||||
isLoading: boolean;
|
||||
sharedSecret?: TViewSharedSecretResponse;
|
||||
decryptedSecret: string;
|
||||
timeLeft: string;
|
||||
isUrlCopied: boolean;
|
||||
copyUrlToClipboard: () => void;
|
||||
};
|
||||
|
||||
export const SecretTable = ({
|
||||
isLoading,
|
||||
sharedSecret,
|
||||
decryptedSecret,
|
||||
timeLeft,
|
||||
isUrlCopied,
|
||||
copyUrlToClipboard
|
||||
}: Props) => {
|
||||
return (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Value</Th>
|
||||
<Th>Valid Until</Th>
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{!isLoading && sharedSecret && decryptedSecret && (
|
||||
<Tr key={sharedSecret.name}>
|
||||
<Td>{sharedSecret.name}</Td>
|
||||
<Td>
|
||||
<div className="flex items-center md:space-x-2">
|
||||
<div className="max-w-[20rem] flex-1 break-words">
|
||||
<SecretInput
|
||||
isVisible
|
||||
value={decryptedSecret}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
ariaLabel="copy to clipboard"
|
||||
onClick={copyUrlToClipboard}
|
||||
className="rounded p-2 hover:bg-gray-700"
|
||||
size="xs"
|
||||
>
|
||||
<FontAwesomeIcon icon={isUrlCopied ? faCheck : faCopy} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Td>
|
||||
<Td>{timeLeft}</Td>
|
||||
</Tr>
|
||||
)}
|
||||
{isLoading && (
|
||||
<Tr>
|
||||
<Td colSpan={4} className="bg-mineshaft-800 text-center text-bunker-400">
|
||||
Loading...
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
{!isLoading && !sharedSecret && (
|
||||
<Tr>
|
||||
<Td colSpan={4} className="bg-mineshaft-800 text-center text-bunker-400">
|
||||
<EmptyState title="No such secret is shared yet!" icon={faKey} />
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
{!isLoading && sharedSecret && !decryptedSecret && (
|
||||
<Tr>
|
||||
<Td colSpan={4} className="bg-mineshaft-800 text-center text-bunker-400">
|
||||
<EmptyState title="Invalid URL to fetch the Secret!" icon={faKey} />
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
</TBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
};
|
@ -0,0 +1,2 @@
|
||||
export { DragonMainImage } from "./MainImage";
|
||||
export { SecretTable } from "./SecretTable";
|
1
frontend/src/views/ShareSecretPublicPage/index.tsx
Normal file
1
frontend/src/views/ShareSecretPublicPage/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
export { ShareSecretPublicPage } from "./ShareSecretPublicPage";
|
Reference in New Issue
Block a user