diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index aa9a20ff8..2bc1d9f26 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -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 diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index ce0e8a724..ffebf920e 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -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; [TableName.SecretTag]: Knex.CompositeTableType; [TableName.SecretImport]: Knex.CompositeTableType; [TableName.Integration]: Knex.CompositeTableType; diff --git a/backend/src/db/migrations/20240426191241_secret_sharing.ts b/backend/src/db/migrations/20240426191241_secret_sharing.ts new file mode 100644 index 000000000..e9bd89f9a --- /dev/null +++ b/backend/src/db/migrations/20240426191241_secret_sharing.ts @@ -0,0 +1,24 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + 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("signedValue").notNullable(); + t.timestamp("expiresAt").notNullable(); + t.uuid("userId").notNullable(); + t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.SecretSharing); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.SecretSharing); +} diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index b4d245579..517b92e74 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -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"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index b23c9e9ca..170d886ec 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -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", diff --git a/backend/src/db/schemas/secret-sharing.ts b/backend/src/db/schemas/secret-sharing.ts new file mode 100644 index 000000000..d53f34c4e --- /dev/null +++ b/backend/src/db/schemas/secret-sharing.ts @@ -0,0 +1,22 @@ +// 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(), + signedValue: z.string(), + expiresAt: z.date(), + userId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TSecretSharing = z.infer; +export type TSecretSharingInsert = Omit, TImmutableDBKeys>; +export type TSecretSharingUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index b24024bd4..8deb6f57e 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -26,6 +26,7 @@ export enum ProjectPermissionSub { SecretRollback = "secret-rollback", SecretApproval = "secret-approval", SecretRotation = "secret-rotation", + SecretSharing = "secret-sharing", Identity = "identity" } @@ -52,6 +53,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens] | [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] | [ProjectPermissionActions, ProjectPermissionSub.SecretRotation] + | [ProjectPermissionActions, ProjectPermissionSub.SecretSharing] | [ProjectPermissionActions, ProjectPermissionSub.Identity] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Project] | [ProjectPermissionActions.Edit, ProjectPermissionSub.Project] @@ -71,6 +73,10 @@ const buildAdminPermissionRules = () => { can(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval); can(ProjectPermissionActions.Delete, ProjectPermissionSub.SecretApproval); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretSharing); + can(ProjectPermissionActions.Create, ProjectPermissionSub.SecretSharing); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.SecretSharing); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRotation); can(ProjectPermissionActions.Create, ProjectPermissionSub.SecretRotation); can(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretRotation); @@ -158,6 +164,10 @@ const buildMemberPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRotation); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretSharing); + can(ProjectPermissionActions.Create, ProjectPermissionSub.SecretSharing); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.SecretSharing); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); can(ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback); @@ -217,6 +227,7 @@ const buildViewerPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRotation); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretSharing); can(ProjectPermissionActions.Read, ProjectPermissionSub.Member); can(ProjectPermissionActions.Read, ProjectPermissionSub.Groups); can(ProjectPermissionActions.Read, ProjectPermissionSub.Role); diff --git a/backend/src/server/config/rateLimiter.ts b/backend/src/server/config/rateLimiter.ts index dfecfb495..ea5ba3410 100644 --- a/backend/src/server/config/rateLimiter.ts +++ b/backend/src/server/config/rateLimiter.ts @@ -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 +}; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 4087ae39b..f8855d11f 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -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"; @@ -199,6 +201,8 @@ export const registerRoutes = async ( const secretVersionTagDAL = secretVersionTagDALFactory(db); const secretBlindIndexDAL = secretBlindIndexDALFactory(db); + const secretSharingDAL = secretSharingDALFactory(db); + const integrationDAL = integrationDALFactory(db); const integrationAuthDAL = integrationAuthDALFactory(db); const webhookDAL = webhookDALFactory(db); @@ -612,6 +616,12 @@ export const registerRoutes = async ( projectEnvDAL, projectBotService }); + + const secretSharingService = secretSharingServiceFactory({ + permissionService, + secretSharingDAL + }); + const sarService = secretApprovalRequestServiceFactory({ permissionService, projectBotService, @@ -851,7 +861,8 @@ export const registerRoutes = async ( secretBlindIndex: secretBlindIndexService, telemetry: telemetryService, projectUserAdditionalPrivilege: projectUserAdditionalPrivilegeService, - identityProjectAdditionalPrivilege: identityProjectAdditionalPrivilegeService + identityProjectAdditionalPrivilege: identityProjectAdditionalPrivilegeService, + secretSharing: secretSharingService }); server.decorate("store", { diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 2744fc153..cbf67ce79 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -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" }); }; diff --git a/backend/src/server/routes/v1/secret-sharing-router.ts b/backend/src/server/routes/v1/secret-sharing-router.ts new file mode 100644 index 000000000..6a7f95565 --- /dev/null +++ b/backend/src/server/routes/v1/secret-sharing-router.ts @@ -0,0 +1,131 @@ +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: "/:projectId", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + projectId: z.string().uuid() + }), + response: { + 200: z.array(SecretSharingSchema) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { projectId } = req.params; + const sharedSecrets = await req.server.services.secretSharing.getSharedSecrets({ + actor: req.permission.type, + actorId: req.permission.id, + projectId, + 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() + }), + response: { + 200: SecretSharingSchema.pick({ name: true, signedValue: true, expiresAt: true }) + } + }, + handler: async (req) => { + const sharedSecret = await req.server.services.secretSharing.getActiveSharedSecretById(req.params.id); + if (!sharedSecret) return undefined; + return { + name: sharedSecret.name, + signedValue: sharedSecret.signedValue, + expiresAt: sharedSecret.expiresAt + }; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + name: z.string(), + signedValue: z.string(), + expiresAt: z.string().refine((date) => new Date(date) > new Date(), { + message: "Expires at should be a future date" + }), + workspaceId: z.string().uuid() + }), + response: { + 200: z.object({ + id: z.string().uuid() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { name, signedValue, expiresAt, workspaceId } = req.body; + const sharedSecret = await req.server.services.secretSharing.createSharedSecret({ + actor: req.permission.type, + actorId: req.permission.id, + projectId: workspaceId, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + name, + signedValue, + expiresAt: new Date(expiresAt) + }); + return { id: sharedSecret.id }; + } + }); + + server.route({ + method: "DELETE", + url: "/:projectId/:sharedSecretId", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + projectId: z.string().uuid(), + sharedSecretId: z.string().uuid() + }), + response: { + 200: SecretSharingSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { projectId, sharedSecretId } = req.params; + const deletedSharedSecret = await req.server.services.secretSharing.deleteSharedSecretById({ + actor: req.permission.type, + actorId: req.permission.id, + projectId, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + sharedSecretId + }); + + return { ...deletedSharedSecret }; + } + }); +}; diff --git a/backend/src/services/secret-sharing/secret-sharing-dal.ts b/backend/src/services/secret-sharing/secret-sharing-dal.ts new file mode 100644 index 000000000..696719a1e --- /dev/null +++ b/backend/src/services/secret-sharing/secret-sharing-dal.ts @@ -0,0 +1,16 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TSecretSharingDALFactory = ReturnType; + +export const secretSharingDALFactory = (db: TDbClient) => { + const sharedSecretOrm = ormify(db, TableName.SecretSharing); + + return { + create: sharedSecretOrm.create, + find: sharedSecretOrm.find, + findById: sharedSecretOrm.findById, + deleteById: sharedSecretOrm.deleteById + }; +}; diff --git a/backend/src/services/secret-sharing/secret-sharing-service.ts b/backend/src/services/secret-sharing/secret-sharing-service.ts new file mode 100644 index 000000000..7fdc8f6aa --- /dev/null +++ b/backend/src/services/secret-sharing/secret-sharing-service.ts @@ -0,0 +1,82 @@ +import { ForbiddenError } from "@casl/ability"; + +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; + +import { TSecretSharingDALFactory } from "./secret-sharing-dal"; +import { TCreateSharedSecretDTO, TDeleteSharedSecretDTO, TSharedSecretPermission } from "./secret-sharing-types"; + +type TSecretSharingServiceFactoryDep = { + permissionService: Pick; + secretSharingDAL: TSecretSharingDALFactory; +}; + +export type TSecretSharingServiceFactory = ReturnType; + +export const secretSharingServiceFactory = ({ + permissionService, + secretSharingDAL +}: TSecretSharingServiceFactoryDep) => { + const createSharedSecret = async (createSharedSecretInput: TCreateSharedSecretDTO) => { + const { actor, actorId, projectId, actorAuthMethod, actorOrgId, name, signedValue, expiresAt } = + createSharedSecretInput; + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.SecretSharing); + const newSharedSecret = await secretSharingDAL.create({ + name, + signedValue, + expiresAt, + userId: actorId + }); + return { id: newSharedSecret.id }; + }; + + const getSharedSecrets = async (getSharedSecretsInput: TSharedSecretPermission) => { + const { actor, actorId, projectId, actorAuthMethod, actorOrgId } = getSharedSecretsInput; + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretSharing); + const userSharedSecrets = await secretSharingDAL.find({ userId: actorId }, { sort: [["expiresAt", "asc"]] }); + return userSharedSecrets; + }; + + const getActiveSharedSecretById = async (sharedSecretId: string) => { + const sharedSecret = await secretSharingDAL.findById(sharedSecretId); + if (sharedSecret && sharedSecret.expiresAt < new Date()) { + return; + } + return sharedSecret; + }; + + const deleteSharedSecretById = async (deleteSharedSecretInput: TDeleteSharedSecretDTO) => { + const { actor, actorId, projectId, actorAuthMethod, actorOrgId, sharedSecretId } = deleteSharedSecretInput; + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.SecretSharing); + const deletedSharedSecret = await secretSharingDAL.deleteById(sharedSecretId); + return deletedSharedSecret; + }; + + return { + createSharedSecret, + getSharedSecrets, + deleteSharedSecretById, + getActiveSharedSecretById + }; +}; diff --git a/backend/src/services/secret-sharing/secret-sharing-types.ts b/backend/src/services/secret-sharing/secret-sharing-types.ts new file mode 100644 index 000000000..c08f42612 --- /dev/null +++ b/backend/src/services/secret-sharing/secret-sharing-types.ts @@ -0,0 +1,19 @@ +import { ActorAuthMethod, ActorType } from "../auth/auth-type"; + +export type TSharedSecretPermission = { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + projectId: string; +}; + +export type TCreateSharedSecretDTO = { + name: string; + signedValue: string; + expiresAt: Date; +} & TSharedSecretPermission; + +export type TDeleteSharedSecretDTO = { + sharedSecretId: string; +} & TSharedSecretPermission; diff --git a/docs/documentation/platform/secret-sharing.mdx b/docs/documentation/platform/secret-sharing.mdx new file mode 100644 index 000000000..1b26c0d8e --- /dev/null +++ b/docs/documentation/platform/secret-sharing.mdx @@ -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. + +![Secret Sharing](../../images/platform/secret-sharing/overview.png) + +3. Click on the **Share Secret** button. + + + Infisical does not store the secret you share. This is a part of our Zero + Knowledge Architecture. + + +4. Enter the secret you want to share and set the expiration time. Click on the **Share Secret** button. + +![Add Sharing Secret](../../images/platform/secret-sharing/new-secret.png) + + + Secret once set cannot be changed. This is to ensure that the secret is not + tampered with. + + +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. + +![Copy URL](../../images/platform/secret-sharing/copy-url.png) + +## 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. + +![Access Shared Secret](../../images/platform/secret-sharing/public-view.png) + +## 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. diff --git a/docs/images/platform/secret-sharing/copy-url.png b/docs/images/platform/secret-sharing/copy-url.png new file mode 100644 index 000000000..89d86ede4 Binary files /dev/null and b/docs/images/platform/secret-sharing/copy-url.png differ diff --git a/docs/images/platform/secret-sharing/new-secret.png b/docs/images/platform/secret-sharing/new-secret.png new file mode 100644 index 000000000..13a587ec9 Binary files /dev/null and b/docs/images/platform/secret-sharing/new-secret.png differ diff --git a/docs/images/platform/secret-sharing/overview.png b/docs/images/platform/secret-sharing/overview.png new file mode 100644 index 000000000..3bdbe8878 Binary files /dev/null and b/docs/images/platform/secret-sharing/overview.png differ diff --git a/docs/images/platform/secret-sharing/public-view.png b/docs/images/platform/secret-sharing/public-view.png new file mode 100644 index 000000000..f1bd9482f Binary files /dev/null and b/docs/images/platform/secret-sharing/public-view.png differ diff --git a/docs/mint.json b/docs/mint.json index 10416eb55..96637bfb3 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -137,6 +137,7 @@ "documentation/platform/secret-rotation/aws-iam" ] }, + "documentation/platform/secret-sharing", { "group": "Dynamic Secrets", "pages": [ diff --git a/frontend/src/components/utilities/cryptography/crypto.ts b/frontend/src/components/utilities/cryptography/crypto.ts index c0e5d4c21..c8383d617 100644 --- a/frontend/src/components/utilities/cryptography/crypto.ts +++ b/frontend/src/components/utilities/cryptography/crypto.ts @@ -224,6 +224,76 @@ const decryptSymmetric = ({ ciphertext, iv, tag, key }: DecryptSymmetricProps): return plaintext; }; +/** + * Return new base64, NaCl, public-secret key pair for signing. + * @returns {Object} obj + * @returns {String} obj.publicKey - base64, NaCl, public key + * @returns {String} obj.secretKey - base64, NaCl, secret key + */ +const generateSignKeyPair = (): { + publicKey: string; + secretKey: string; +} => { + const pair = nacl.sign.keyPair(); + + return { + publicKey: nacl.util.encodeBase64(pair.publicKey), + secretKey: nacl.util.encodeBase64(pair.secretKey) + }; +}; + +type SignAsymmetricProps = { + message: string; + privateKey: string; +}; + +/** + * Returns asymmetrically signed [message] using [privateKey] + * @param {Object} obj + * @param {String} obj.message - message to sign + * @param {String} obj.privateKey - base64-encoded private key + * @returns {String} signedMessage - base64-encoded signed message + */ +const signAssymmetric = ({ message, privateKey }: SignAsymmetricProps): string => { + let signedMessage; + try { + signedMessage = nacl.sign(nacl.util.decodeUTF8(message), nacl.util.decodeBase64(privateKey)); + } catch (err) { + console.log("Failed to sign message", err); + process.exit(1); + } + return nacl.util.encodeBase64(signedMessage); +}; + +type OpenSignedAsymmetricProps = { + signedMessage: string; + publicKey: string; +}; + +/** + * Returns asymmetrically decrypted [message] using [publicKey] + * @param {Object} obj + * @param {String} obj.signedMessage - signed message to decrypt + * @param {String} obj.publicKey - base64-encoded public key + * @returns {String} signedMessage - base64-encoded decrypted message + */ +const openSignedAssymmetric = ({ signedMessage, publicKey }: OpenSignedAsymmetricProps): string => { + let originalMessage; + try { + originalMessage = nacl.sign.open( + nacl.util.decodeBase64(signedMessage), + nacl.util.decodeBase64(publicKey) + ); + if (!originalMessage) { + throw new Error("Signature verification failed"); + } + originalMessage = nacl.util.encodeUTF8(originalMessage); + } catch (err) { + console.log("Failed to verify signature", err); + } + return originalMessage; +}; + export { decryptAssymmetric, decryptSymmetric, @@ -231,5 +301,8 @@ export { encryptAssymmetric, encryptSymmetric, generateKeyPair, + generateSignKeyPair, + openSignedAssymmetric, + signAssymmetric, verifyPrivateKey }; diff --git a/frontend/src/const.ts b/frontend/src/const.ts index 68ba1c497..4d13b4602 100644 --- a/frontend/src/const.ts +++ b/frontend/src/const.ts @@ -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 = { diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index 79c8f2d30..bc383de26 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -24,6 +24,7 @@ export enum ProjectPermissionSub { SecretRollback = "secret-rollback", SecretApproval = "secret-approval", SecretRotation = "secret-rotation", + SecretSharing = "secret-sharing", Identity = "identity" } @@ -51,6 +52,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens] | [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] | [ProjectPermissionActions, ProjectPermissionSub.SecretRotation] + | [ProjectPermissionActions, ProjectPermissionSub.SecretSharing] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Workspace] | [ProjectPermissionActions.Edit, ProjectPermissionSub.Workspace] | [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback] diff --git a/frontend/src/hooks/api/secretSharing/index.ts b/frontend/src/hooks/api/secretSharing/index.ts new file mode 100644 index 000000000..177955438 --- /dev/null +++ b/frontend/src/hooks/api/secretSharing/index.ts @@ -0,0 +1,3 @@ +export * from "./mutations"; +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/secretSharing/mutations.ts b/frontend/src/hooks/api/secretSharing/mutations.ts new file mode 100644 index 000000000..a3566282c --- /dev/null +++ b/frontend/src/hooks/api/secretSharing/mutations.ts @@ -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("/api/v1/secret-sharing", inputData); + return data; + }, + onSuccess: () => queryClient.invalidateQueries(["sharedSecrets"]) + }); +}; + +export const useDeleteSharedSecret = () => { + const queryClient = useQueryClient(); + return useMutation< + TSharedSecret, + { message: string }, + { sharedSecretId: string; workspaceId: string } + >({ + mutationFn: async ({ sharedSecretId, workspaceId }: TDeleteSharedSecretRequest) => { + const { data } = await apiRequest.delete( + `/api/v1/secret-sharing/${workspaceId}/${sharedSecretId}` + ); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries(["sharedSecrets"]); + } + }); +}; diff --git a/frontend/src/hooks/api/secretSharing/queries.ts b/frontend/src/hooks/api/secretSharing/queries.ts new file mode 100644 index 000000000..33ffddfbc --- /dev/null +++ b/frontend/src/hooks/api/secretSharing/queries.ts @@ -0,0 +1,32 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TSharedSecret, TViewSharedSecretResponse } from "./types"; + +export const useGetSharedSecrets = (workspaceId: string) => { + return useQuery({ + queryKey: ["sharedSecrets"], + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/secret-sharing/${workspaceId}` + ); + return data; + } + }); +}; + +export const useGetActiveSharedSecretById = (id: string) => { + return useQuery({ + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/secret-sharing/public/${id}` + ); + return { + name: data.name, + signedValue: data.signedValue, + expiresAt: data.expiresAt + }; + } + }); +}; diff --git a/frontend/src/hooks/api/secretSharing/types.ts b/frontend/src/hooks/api/secretSharing/types.ts new file mode 100644 index 000000000..6afbe34ea --- /dev/null +++ b/frontend/src/hooks/api/secretSharing/types.ts @@ -0,0 +1,27 @@ +export type TSharedSecret = { + id: string; + name: string; + signedValue: string; + userId: string; + expiresAt: Date; + createdAt: Date; + updatedAt: Date; +}; + +export type TCreateSharedSecretRequest = { + name: string; + signedValue: string; + expiresAt: Date; + workspaceId: string; +}; + +export type TViewSharedSecretResponse = { + name: string; + signedValue: string; + expiresAt: Date; +}; + +export type TDeleteSharedSecretRequest = { + sharedSecretId: string; + workspaceId: string; +}; diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 2fcdc9339..60c31b72c 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -531,6 +531,18 @@ export const AppLayout = ({ children }: LayoutProps) => { + + + + Secret Sharing + + + { + const { t } = useTranslation(); + + return ( + <> + + {t("common.head-title", { title: t("approval.title") })} + + + + + +
+ +
+ + ); +}; + +export default SecretApproval; + +SecretApproval.requireAuth = true; diff --git a/frontend/src/pages/shared/secret/[id]/index.tsx b/frontend/src/pages/shared/secret/[id]/index.tsx new file mode 100644 index 000000000..7f53d962d --- /dev/null +++ b/frontend/src/pages/shared/secret/[id]/index.tsx @@ -0,0 +1,24 @@ +import Head from "next/head"; + +import { ShareSecretPublicPage } from "@app/views/ShareSecretPublicPage"; + +const SecretApproval = () => { + return ( + <> + + Securely Share Secrets | Infisical + + + + + +
+ +
+ + ); +}; + +export default SecretApproval; + +SecretApproval.requireAuth = false; diff --git a/frontend/src/views/ShareSecretPage/ShareSecretPage.tsx b/frontend/src/views/ShareSecretPage/ShareSecretPage.tsx new file mode 100644 index 000000000..74db4f909 --- /dev/null +++ b/frontend/src/views/ShareSecretPage/ShareSecretPage.tsx @@ -0,0 +1,31 @@ +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 ( +
+
+
+

Secret Sharing

+

Share secrets with anybody securely and efficiently

+
+
+ {/* Add docs here */} + + + Documentation{" "} + + + +
+
+ +
+ ); +}; diff --git a/frontend/src/views/ShareSecretPage/components/AddShareSecretModal.tsx b/frontend/src/views/ShareSecretPage/components/AddShareSecretModal.tsx new file mode 100644 index 000000000..39e026f63 --- /dev/null +++ b/frontend/src/views/ShareSecretPage/components/AddShareSecretModal.tsx @@ -0,0 +1,283 @@ +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 { + generateSignKeyPair, + signAssymmetric +} from "@app/components/utilities/cryptography/crypto"; +import { + Button, + FormControl, + IconButton, + Input, + Modal, + ModalClose, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useToggle } 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; + +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({ + resolver: yupResolver(schema) + }); + const createSharedSecret = useCreateSharedSecret(); + const { currentWorkspace } = useWorkspace(); + const [newSharedSecret, setnewSharedSecret] = useState(""); + const [isUrlCopied, setIsUrlCopied] = useToggle(false); + const hasSharedSecret = Boolean(newSharedSecret); + + useEffect(() => { + let timer: NodeJS.Timeout; + if (isUrlCopied) { + timer = setTimeout(() => setIsUrlCopied.off(), 2000); + } + + return () => clearTimeout(timer); + }, [isUrlCopied]); + + const copyUrlToClipboard = () => { + navigator.clipboard.writeText(newSharedSecret); + setIsUrlCopied.on(); + }; + + const onFormSubmit = async ({ name, value, expiresInValue, expiresInUnit }: FormData) => { + try { + if (!currentWorkspace?.id) return; + + const signingKeyPair = generateSignKeyPair(); + const signedMessage = signAssymmetric({ + message: value, + privateKey: signingKeyPair.secretKey + }); + + const expiresAt = new Date(); + const updateExpiresAt = expirationUnitsAndActions.find( + (item) => item.unit === expiresInUnit + )?.action; + if (updateExpiresAt) { + updateExpiresAt(expiresAt, expiresInValue); + } + + const { id } = await createSharedSecret.mutateAsync({ + name, + signedValue: signedMessage, + expiresAt, + workspaceId: currentWorkspace.id + }); + setnewSharedSecret( + `${window.location.origin}/shared/secret/${id}?key=${encodeURIComponent( + signingKeyPair.publicKey + )}` + ); + + 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 ( + { + handlePopUpToggle("createSharedSecret", open); + reset(); + setnewSharedSecret(""); + }} + > + + {!hasSharedSecret ? ( +
+ ( + + + + )} + /> + ( + + + + )} + /> +
+
+ ( + + + + )} + /> +
+
+ ( + + + + )} + /> +
+
+
+ + + + +
+ + ) : ( +
+

{newSharedSecret}

+ + + + Click to Copy + + +
+ )} +
+
+ ); +}; diff --git a/frontend/src/views/ShareSecretPage/components/ShareSecretSection.tsx b/frontend/src/views/ShareSecretPage/components/ShareSecretSection.tsx new file mode 100644 index 000000000..971b77261 --- /dev/null +++ b/frontend/src/views/ShareSecretPage/components/ShareSecretSection.tsx @@ -0,0 +1,108 @@ +import { useState } from "react"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Button, Checkbox, DeleteActionModal } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; +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 = withProjectPermission( + () => { + const { currentWorkspace } = useWorkspace(); + const deleteSharedSecret = useDeleteSharedSecret(); + const [showExpiredSharedSecrets, setShowExpiredSharedSecrets] = useState(false); + + const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([ + "createSharedSecret", + "deleteSharedSecretConfirmation" + ] as const); + + const onDeleteApproved = async () => { + try { + if (!currentWorkspace?.id) return; + deleteSharedSecret.mutateAsync({ + sharedSecretId: (popUp?.deleteSharedSecretConfirmation?.data as DeleteModalData)?.id, + workspaceId: currentWorkspace.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 ( +
+
+

Shared Secrets

+ + {(isAllowed) => ( + + )} + +
+
+

+ Every secret shared can be accessed with the URL (shown during creation) before its + expiry. +

+ { + setShowExpiredSharedSecrets(state as boolean); + }} + > + Show expired shared secrets too + +
+ + + handlePopUpToggle("deleteSharedSecretConfirmation", isOpen)} + deleteKey={(popUp?.deleteSharedSecretConfirmation?.data as DeleteModalData)?.name} + onClose={() => handlePopUpClose("deleteSharedSecretConfirmation")} + onDeleteApproved={onDeleteApproved} + /> +
+ ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.SecretSharing } +); diff --git a/frontend/src/views/ShareSecretPage/components/ShareSecretsRow.tsx b/frontend/src/views/ShareSecretPage/components/ShareSecretsRow.tsx new file mode 100644 index 000000000..88902ecc3 --- /dev/null +++ b/frontend/src/views/ShareSecretPage/components/ShareSecretsRow.tsx @@ -0,0 +1,146 @@ +import { useEffect, useState } from "react"; +import { faTrashCan } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { IconButton, Td, Tr } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +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)); + + console.log( + elapsedYears, + elapsedMonths, + elapsedWeeks, + elapsedDays, + elapsedHours, + elapsedMinutes, + elapsedSeconds + ); + + 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 ( + + {row.name} + +

{timeAgo(row.createdAt, currentTime)}

+

{formatDate(row.createdAt)}

+ + +

+ {getValidityStatusText(row.expiresAt) + timeAgo(row.expiresAt, currentTime)} +

+

{formatDate(row.expiresAt)}

+ + + + {(isAllowed) => ( + + handlePopUpOpen("deleteSharedSecretConfirmation", { + name: row.name, + id: row.id + }) + } + colorSchema="danger" + ariaLabel="delete" + isDisabled={!isAllowed} + > + + + )} + + + + ); +}; diff --git a/frontend/src/views/ShareSecretPage/components/ShareSecretsTable.tsx b/frontend/src/views/ShareSecretPage/components/ShareSecretsTable.tsx new file mode 100644 index 000000000..eac3435e3 --- /dev/null +++ b/frontend/src/views/ShareSecretPage/components/ShareSecretsTable.tsx @@ -0,0 +1,91 @@ +import { useEffect, useState } from "react"; +import { faKey } from "@fortawesome/free-solid-svg-icons"; + +import { + EmptyState, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { TSharedSecret, 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; + showExpiredSharedSecrets: boolean; +}; + +export const ShareSecretsTable = ({ handlePopUpOpen, showExpiredSharedSecrets }: Props) => { + const [tableData, setTableData] = useState([]); + const { currentWorkspace } = useWorkspace(); + const workspaceId = currentWorkspace?.id || ""; + const { isLoading, data = [] } = useGetSharedSecrets(workspaceId); + + useEffect(() => { + if (!isLoading) { + if (!showExpiredSharedSecrets) { + setTableData(data.filter((secret) => new Date(secret.expiresAt) > new Date())); + } else { + setTableData(data); + } + } + }, [isLoading, data, showExpiredSharedSecrets]); + + const handleSecretExpiration = () => { + if (!showExpiredSharedSecrets) { + setTableData( + data.filter((secret) => !secret.expiresAt || new Date(secret.expiresAt) > new Date()) + ); + } + }; + + return ( + + + + + + + + + {isLoading && } + {!isLoading && + tableData && + tableData.map((row) => ( + + ))} + {!isLoading && tableData && tableData?.length === 0 && ( + + + + )} + +
Secret Name Created Valid Until +
+ +
+
+ ); +}; diff --git a/frontend/src/views/ShareSecretPage/components/index.tsx b/frontend/src/views/ShareSecretPage/components/index.tsx new file mode 100644 index 000000000..64a0c2774 --- /dev/null +++ b/frontend/src/views/ShareSecretPage/components/index.tsx @@ -0,0 +1 @@ +export { ShareSecretSection } from "./ShareSecretSection"; diff --git a/frontend/src/views/ShareSecretPage/index.tsx b/frontend/src/views/ShareSecretPage/index.tsx new file mode 100644 index 000000000..fa8198494 --- /dev/null +++ b/frontend/src/views/ShareSecretPage/index.tsx @@ -0,0 +1 @@ +export { ShareSecretPage } from "./ShareSecretPage"; diff --git a/frontend/src/views/ShareSecretPublicPage/ShareSecretPublicPage.tsx b/frontend/src/views/ShareSecretPublicPage/ShareSecretPublicPage.tsx new file mode 100644 index 000000000..ab34a8a14 --- /dev/null +++ b/frontend/src/views/ShareSecretPublicPage/ShareSecretPublicPage.tsx @@ -0,0 +1,113 @@ +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 { openSignedAssymmetric } from "@app/components/utilities/cryptography/crypto"; +import { useToggle } from "@app/hooks"; +import { useGetActiveSharedSecretById } from "@app/hooks/api/secretSharing"; + +import { DragonMainImage, SecretTable } from "./components"; + +export const ShareSecretPublicPage = () => { + const router = useRouter(); + const { id, key: urlEncodedPublicKey } = router.query; + + const publicKey = decodeURIComponent(urlEncodedPublicKey as string); + useEffect(() => { + if (!id || !publicKey) { + router.push("/404"); + } + }, [id, publicKey]); + + const { isLoading, data } = useGetActiveSharedSecretById(id as string); + const decryptedSecret = useMemo(() => { + if (data && data.signedValue && publicKey) { + const res = openSignedAssymmetric({ + signedMessage: data.signedValue, + publicKey: publicKey as string + }); + return res; + } + return ""; + }, [data, publicKey]); + + const [timeLeft, setTimeLeft] = useState(""); + const [isUrlCopied, setIsUrlCopied] = useToggle(false); + + useEffect(() => { + const updateTimer = () => { + if (data && data.expiresAt) { + const expiryDate = new Date(data.expiresAt).getTime(); + const now = new Date().getTime(); + const distance = expiryDate - now; + + if (distance < 0) { + setTimeLeft("Expired"); + } else { + const hours = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + const minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((distance % (1000 * 60)) / 1000); + setTimeLeft(`${hours}h ${minutes}m ${seconds}s`); + } + } + }; + + const timer = setInterval(updateTimer, 1000); + return () => clearInterval(timer); + }, [data?.expiresAt]); + + useEffect(() => { + let timer: NodeJS.Timeout; + if (isUrlCopied) { + timer = setTimeout(() => setIsUrlCopied.off(), 2000); + } + + return () => clearTimeout(timer); + }, [isUrlCopied]); + + const copyUrlToClipboard = () => { + navigator.clipboard.writeText(decryptedSecret as string); + setIsUrlCopied.on(); + }; + + return ( +
+ ); +}; diff --git a/frontend/src/views/ShareSecretPublicPage/components/MainImage.tsx b/frontend/src/views/ShareSecretPublicPage/components/MainImage.tsx new file mode 100644 index 000000000..49a7e17ed --- /dev/null +++ b/frontend/src/views/ShareSecretPublicPage/components/MainImage.tsx @@ -0,0 +1,14 @@ +import Image from "next/image"; + +export const DragonMainImage = () => { + return ( +
+ Infisical Dragon - Came to send you a secret! +
+ ); +}; diff --git a/frontend/src/views/ShareSecretPublicPage/components/SecretTable.tsx b/frontend/src/views/ShareSecretPublicPage/components/SecretTable.tsx new file mode 100644 index 000000000..f459b9547 --- /dev/null +++ b/frontend/src/views/ShareSecretPublicPage/components/SecretTable.tsx @@ -0,0 +1,89 @@ +import { faCheck, faCopy, faKey } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { + EmptyState, + IconButton, + 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 ( + + + + + + + + + + + {!isLoading && sharedSecret && decryptedSecret && ( + + + + + + )} + {isLoading && ( + + + + )} + {!isLoading && !sharedSecret && ( + + + + )} + {!isLoading && sharedSecret && !decryptedSecret && ( + + + + )} + +
NameValueValid Until
{sharedSecret.name} +
+
{decryptedSecret}
+ + + +
+
{timeLeft}
+ Loading... +
+ +
+ +
+
+ ); +}; diff --git a/frontend/src/views/ShareSecretPublicPage/components/index.tsx b/frontend/src/views/ShareSecretPublicPage/components/index.tsx new file mode 100644 index 000000000..5a7b53a0d --- /dev/null +++ b/frontend/src/views/ShareSecretPublicPage/components/index.tsx @@ -0,0 +1,2 @@ +export { DragonMainImage } from "./MainImage"; +export { SecretTable } from "./SecretTable"; diff --git a/frontend/src/views/ShareSecretPublicPage/index.tsx b/frontend/src/views/ShareSecretPublicPage/index.tsx new file mode 100644 index 000000000..778e8ee58 --- /dev/null +++ b/frontend/src/views/ShareSecretPublicPage/index.tsx @@ -0,0 +1 @@ +export { ShareSecretPublicPage } from "./ShareSecretPublicPage";